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.
Files changed (164) hide show
  1. package/BIBLIOGRAPHY.md +132 -108
  2. package/CHANGELOG.md +598 -1
  3. package/LICENSE +133 -29
  4. package/README.md +106 -83
  5. package/dist/data/am.js +2666 -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 +17 -0
  21. package/dist/data/corpus-overlap.js +299 -5
  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 +754 -6394
  29. package/dist/data/kyriale.js +116 -116
  30. package/dist/data/la.js +799 -13419
  31. package/dist/data/lh.js +113 -3473
  32. package/dist/data/lu.js +931 -17631
  33. package/dist/data/nocturnale-romanum.js +1659 -10411
  34. package/dist/data/office-ferial.d.ts +4 -0
  35. package/dist/data/office-ferial.js +396 -0
  36. package/dist/data/office-ferial.json +391 -0
  37. package/dist/data/office-monastic.d.ts +17 -1
  38. package/dist/data/office-monastic.js +1403 -466
  39. package/dist/data/office-psalms-monastic.d.ts +13 -1
  40. package/dist/data/office-psalms-monastic.js +9 -0
  41. package/dist/data/propers.js +1 -1
  42. package/dist/data/psalms.js +22919 -5
  43. package/dist/data/psm.d.ts +5 -0
  44. package/dist/data/psm.js +122 -0
  45. package/dist/data/seasonal-respbreve.d.ts +5 -0
  46. package/dist/data/seasonal-respbreve.js +41 -0
  47. package/dist/data/seasonal-respbreve.json +35 -0
  48. package/dist/data/smufl-glyphs.d.ts +17 -0
  49. package/dist/data/smufl-glyphs.js +1546 -0
  50. package/dist/data/smufl-glyphs.json +1530 -0
  51. package/dist/engines/cal/calendar.d.ts +3 -2
  52. package/dist/engines/cal/calendar.js +105 -29
  53. package/dist/engines/cal/data/eras.d.ts +35 -0
  54. package/dist/engines/cal/data/eras.js +128 -0
  55. package/dist/engines/cal/date.js +44 -0
  56. package/dist/engines/cal/types.d.ts +15 -3
  57. package/dist/engines/cal/types.js +5 -5
  58. package/dist/engines/census/census.d.ts +7 -0
  59. package/dist/engines/census/census.js +179 -0
  60. package/dist/engines/census/types.d.ts +55 -0
  61. package/dist/engines/census/types.js +8 -0
  62. package/dist/engines/chant/attest.d.ts +39 -0
  63. package/dist/engines/chant/attest.js +90 -0
  64. package/dist/engines/chant/chant.d.ts +16 -4
  65. package/dist/engines/chant/chant.js +220 -30
  66. package/dist/engines/chant/data/compline.js +2 -1
  67. package/dist/engines/chant/data/masses.d.ts +56 -4
  68. package/dist/engines/chant/data/masses.js +305 -80
  69. package/dist/engines/chant/data/prime.js +1 -1
  70. package/dist/engines/chant/hour.js +279 -58
  71. package/dist/engines/chant/ordinary.d.ts +2 -0
  72. package/dist/engines/chant/ordinary.js +336 -56
  73. package/dist/engines/chant/propers.js +55 -5
  74. package/dist/engines/chant/psalm.d.ts +4 -4
  75. package/dist/engines/chant/psalm.js +25 -11
  76. package/dist/engines/chant/syllabify.d.ts +1 -0
  77. package/dist/engines/chant/syllabify.js +90 -17
  78. package/dist/engines/chant/types.d.ts +115 -12
  79. package/dist/engines/chant/types.js +38 -3
  80. package/dist/engines/harmonia/api.js +4 -0
  81. package/dist/engines/harmonia/data/doctrines.js +3 -1
  82. package/dist/engines/harmonia/tabula.d.ts +3 -0
  83. package/dist/engines/harmonia/tabula.js +1 -0
  84. package/dist/engines/harmonia/voice.d.ts +4 -0
  85. package/dist/engines/harmonia/voice.js +8 -4
  86. package/dist/engines/imprint.js +14 -1
  87. package/dist/engines/planet/orbital.js +4 -4
  88. package/dist/engines/planet/planet.d.ts +10 -0
  89. package/dist/engines/planet/planet.js +30 -3
  90. package/dist/engines/planet/position.js +13 -10
  91. package/dist/engines/planet/types.d.ts +1 -0
  92. package/dist/engines/score/api.d.ts +2 -13
  93. package/dist/engines/score/api.js +21 -8
  94. package/dist/engines/score/articulation.js +2 -2
  95. package/dist/engines/score/cadence.d.ts +76 -0
  96. package/dist/engines/score/cadence.js +96 -0
  97. package/dist/engines/score/emitters/accidentals.d.ts +21 -0
  98. package/dist/engines/score/emitters/accidentals.js +88 -0
  99. package/dist/engines/score/emitters/atramentum.d.ts +107 -0
  100. package/dist/engines/score/emitters/atramentum.js +239 -0
  101. package/dist/engines/score/emitters/breaking.d.ts +62 -0
  102. package/dist/engines/score/emitters/breaking.js +80 -0
  103. package/dist/engines/score/emitters/moderna.d.ts +38 -0
  104. package/dist/engines/score/emitters/moderna.js +612 -0
  105. package/dist/engines/score/emitters/svg.d.ts +143 -0
  106. package/dist/engines/score/emitters/svg.js +1328 -0
  107. package/dist/engines/score/emitters/tracks.d.ts +104 -0
  108. package/dist/engines/score/emitters/tracks.js +728 -0
  109. package/dist/engines/score/infer.d.ts +3 -3
  110. package/dist/engines/score/infer.js +2 -2
  111. package/dist/engines/score/inscriptio.d.ts +69 -0
  112. package/dist/engines/score/inscriptio.js +138 -0
  113. package/dist/engines/score/ir.d.ts +2 -2
  114. package/dist/engines/score/ir.js +59 -12
  115. package/dist/engines/score/lyric.d.ts +23 -0
  116. package/dist/engines/score/lyric.js +234 -0
  117. package/dist/engines/score/meta.d.ts +2 -2
  118. package/dist/engines/score/modulation.d.ts +12 -0
  119. package/dist/engines/score/modulation.js +49 -0
  120. package/dist/engines/score/neume.js +35 -4
  121. package/dist/engines/score/parse.js +150 -9
  122. package/dist/engines/score/phrasing.js +4 -3
  123. package/dist/engines/score/prosody.d.ts +38 -0
  124. package/dist/engines/score/prosody.js +70 -6
  125. package/dist/engines/score/tabula.d.ts +37 -5
  126. package/dist/engines/score/tabula.js +18 -0
  127. package/dist/engines/score/types.d.ts +88 -1
  128. package/dist/engines/temper/api.d.ts +4 -1
  129. package/dist/engines/temper/api.js +28 -5
  130. package/dist/engines/temper/data/guido.js +6 -2
  131. package/dist/engines/temper/data/modes.d.ts +6 -0
  132. package/dist/engines/temper/data/modes.js +42 -0
  133. package/dist/engines/temper/data/tones.d.ts +1 -1
  134. package/dist/engines/temper/data/tones.js +20 -11
  135. package/dist/engines/temper/interval.js +4 -3
  136. package/dist/engines/temper/modality.d.ts +11 -2
  137. package/dist/engines/temper/modality.js +74 -2
  138. package/dist/engines/temper/modes.d.ts +1 -1
  139. package/dist/engines/temper/pitch.d.ts +1 -1
  140. package/dist/engines/temper/pitch.js +12 -2
  141. package/dist/engines/temper/scale.d.ts +53 -0
  142. package/dist/engines/temper/scale.js +107 -8
  143. package/dist/index.d.ts +26 -8
  144. package/dist/index.js +37 -4
  145. package/docs/api/calendar.md +279 -0
  146. package/docs/api/census.md +288 -0
  147. package/docs/api/chant.md +657 -0
  148. package/docs/api/heavens.md +346 -0
  149. package/docs/api/index.md +263 -0
  150. package/docs/api/score.md +873 -0
  151. package/docs/api/tuning.md +619 -0
  152. package/package.json +11 -5
  153. package/dist/data/office-matins-roman.d.ts +0 -19
  154. package/dist/data/office-matins-roman.js +0 -4383
  155. package/dist/data/office-psalms-roman.d.ts +0 -15
  156. package/dist/data/office-psalms-roman.js +0 -28
  157. package/dist/data/office-roman.d.ts +0 -19
  158. package/dist/data/office-roman.js +0 -13792
  159. package/dist/engines/chant/matutinum.d.ts +0 -33
  160. package/dist/engines/chant/matutinum.js +0 -81
  161. package/dist/engines/score/emitters/midi.d.ts +0 -65
  162. package/dist/engines/score/emitters/midi.js +0 -162
  163. package/dist/engines/score/emitters/musicxml.d.ts +0 -18
  164. package/dist/engines/score/emitters/musicxml.js +0 -166
@@ -2,15 +2,17 @@
2
2
  // engines/chant/psalm — psalm and canticle retrieval as intoned Chant[]
3
3
  // ---------------------------------------------------------------------------
4
4
  import { PSALMS } from "../../data/psalms.js";
5
- import { OFFICE_PSALMS, } from "../../data/office-psalms-roman.js";
6
- import { OFFICE_PSALMS_MONASTIC } from "../../data/office-psalms-monastic.js";
5
+ import { OFFICE_PSALMS_MONASTIC, } from "../../data/office-psalms-monastic.js";
7
6
  import { intone } from "./intone.js";
8
- import { MODE_LABELS } from "./types.js";
7
+ import { MODI } from "./types.js";
8
+ // Canticle numbers follow the generated psalms.json numbering (verified against
9
+ // the incipits: 231 "Benedíctus Dóminus", 232 "Magníficat", 233 "Nunc dimíttis",
10
+ // 210 "Benedícite, ómnia ópera"). The Te Deum is not psalmody — it carries its
11
+ // own melody, has no rows in the psalter data, and is not addressable here.
9
12
  const CANTICLE_NAMES = {
10
13
  benedictus: 231,
11
- magnificat: 234,
12
- "nunc dimittis": 227,
13
- "te deum": 240,
14
+ magnificat: 232,
15
+ "nunc dimittis": 233,
14
16
  benedicite: 210,
15
17
  };
16
18
  function lookupVerses(psalm, verse) {
@@ -37,15 +39,20 @@ function verseToChant(v, mode, differentia, intonation, inDirectum, solemn) {
37
39
  office: "ps",
38
40
  genus: "Psalmus",
39
41
  mode: String(mode),
40
- modus: mode === 0 ? "Tonus Peregrinus" : (MODE_LABELS[String(mode)] ?? `Modus ${mode}`),
42
+ modus: mode === 0 ? "Tonus Peregrinus" : (MODI[String(mode)] ?? `Modus ${mode}`),
41
43
  pages: [],
42
44
  source: {
43
45
  book: "Psalterium",
44
- year: new Date().getUTCFullYear(),
46
+ // Deterministic: intoned psalmody is computed, not dated — the library's
47
+ // determinism doctrine forbids a wall-clock stamp here.
48
+ year: null,
45
49
  editor: "tonus",
46
50
  },
47
51
  };
48
52
  }
53
+ const PSALMUS_QUERY_KEYS = new Set([
54
+ "psalm", "verse", "mode", "differentia", "intonatio", "inDirectum", "solemn",
55
+ ]);
49
56
  /**
50
57
  * Psalm and canticle retrieval (`tonus.psalmus`) from the Psalterium,
51
58
  * intoned to the psalm tones (modes 1-8 plus tonus peregrinus) as GABC.
@@ -53,6 +60,13 @@ function verseToChant(v, mode, differentia, intonation, inDirectum, solemn) {
53
60
  export function getPsalm(query) {
54
61
  if (!query || Object.keys(query).length === 0)
55
62
  return [];
63
+ // The same door policy as cantus and officium: an unknown key throws, so a
64
+ // stale or misspelled option is learned immediately, not silently ignored.
65
+ const unknown = Object.keys(query).filter((k) => !PSALMUS_QUERY_KEYS.has(k));
66
+ if (unknown.length) {
67
+ throw new Error(`psalmus: unknown query key(s) ${unknown.map((k) => `"${k}"`).join(", ")} ` +
68
+ `(expected ${[...PSALMUS_QUERY_KEYS].join(", ")}).`);
69
+ }
56
70
  const verses = lookupVerses(query.psalm ?? 0, query.verse);
57
71
  if (!verses.length)
58
72
  return [];
@@ -83,12 +97,12 @@ export function intonePortion(p, mode = 8) {
83
97
  }
84
98
  /**
85
99
  * The little-hours psalmody for one hour on a given weekday (0 = Sunday), from
86
- * the extracted DO Tridentine scheme (`office-psalms-roman.ts`). Prefers the
100
+ * the extracted DO monastic scheme (`office-psalms-monastic.ts`). Prefers the
87
101
  * weekday-specific entry, then the ferial default (weekday null), then the
88
102
  * feast set; returns the psalm portions (not yet intoned).
89
103
  */
90
- export function officePsalmPortions(hour, weekday, rite = "romanum") {
91
- const scheme = rite === "monasticum" ? OFFICE_PSALMS_MONASTIC : OFFICE_PSALMS;
104
+ export function officePsalmPortions(hour, weekday) {
105
+ const scheme = OFFICE_PSALMS_MONASTIC;
92
106
  const forHour = scheme.filter((e) => e.hour === hour);
93
107
  const exact = forHour.find((e) => e.weekday === weekday && !e.festis);
94
108
  const ferial = forHour.find((e) => e.weekday === null && !e.festis);
@@ -15,6 +15,7 @@ export declare function hyphenateWord(word: string): string;
15
15
  export declare function selectVowel(text: string): {
16
16
  vowel: string;
17
17
  accent: boolean;
18
+ diphthong: string | null;
18
19
  };
19
20
  export declare function detectVowelAccent(text: string): boolean;
20
21
  //# sourceMappingURL=syllabify.d.ts.map
@@ -3,21 +3,30 @@
3
3
  // ---------------------------------------------------------------------------
4
4
  //
5
5
  // Rules applied (in order):
6
- // 1. Digraphs ae, oe, au, ei, eu → single vowel unit (never split)
7
- // 2. qu → treated as single consonant (u is silent after q)
8
- // 3. V-ia/ie/io/iu/ua/ue/uo-V → always split before the i/u (ecclesiastical)
9
- // Exception: ui after l/r is a diphthong (alleluia, huius)
10
- // 4. Single consonant between vowels → goes with following vowel (V·CV)
11
- // 5. Consonant clusters: muta cum liquida (tr, pr, br, gr, dr, cr, fr, pl, bl,
12
- // cl, gl, fl) stay together; all other clusters split after first consonant
6
+ // 1. Diphthongs ae, oe, au → single vowel unit (never split). ei and ui are
7
+ // NOT diphthongs in ecclesiastical Latin (De-i, e-le-i-son, fu-it, su-i)
8
+ // — except the pronoun stems cui/hui (cui, huic), where ui is true.
9
+ // eu is NOT a diphthong either (de-us, me-us split normally).
10
+ // 2. qu → single consonant (u is silent after q); likewise the ngu glide
11
+ // (lin-gua, sán-guis) — but not gu elsewhere (e-xi-gu-us, ar-gu-it).
12
+ // 3. i between vowels, or word-initial before a vowel, is consonantal (the
13
+ // j sound): e-ius, ma-ior, al-le-lu-ia, Ie-su — it opens the next
14
+ // syllable rather than closing this one.
15
+ // 4. All other adjacent vowels split (ecclesiastical): fi-li-a, glo-ri-a
16
+ // 5. Single consonant between vowels → goes with following vowel (V·CV)
17
+ // 6. Consonant clusters: muta cum liquida (tr, pr, br, gr, dr, cr, fr, pl, bl,
18
+ // cl, gl, fl) and the Greek digraphs th, ph, ch stay with the following
19
+ // vowel; all other clusters split after the first consonant
13
20
  //
14
21
  // Diacritics (áéíóúàèìòùâêîôû etc.) are treated as their base vowel throughout.
15
22
  // ── Character classes ──
16
23
  const VOWELS = new Set("aeiouyáéíóúàèìòùâêîôûäëïöüæœý");
17
24
  const SOFT_HYPHEN = "\u00ad";
18
- // Latin diphthongs: ae, oe, au, ei are single syllable.
19
- // eu is not a classical Latin diphthong (de-us, me-us split normally).
20
- const DIPHTHONGS = new Set(["ae", "oe", "au", "ei", "ui"]);
25
+ // Latin diphthongs treated as single syllables. ei and ui are deliberately
26
+ // absent: in ecclesiastical Latin they are hiatus (De-i, fu-it, su-i) — ui is
27
+ // a true diphthong only in the pronoun stems cui/hui, handled below. eu is not
28
+ // a classical Latin diphthong (de-us, me-us split normally).
29
+ const DIPHTHONGS = new Set(["ae", "oe", "au"]);
21
30
  // Muta cum liquida pairs that stay with the following vowel
22
31
  const MUTA_CUM_LIQUIDA = new Set([
23
32
  "tr",
@@ -58,16 +67,40 @@ function isConsonantBase(ch) {
58
67
  * "ánima" → ["á", "ni", "ma"]
59
68
  */
60
69
  export function syllabifyWord(word) {
61
- if (word.length <= 2)
70
+ if (word.length <= 1)
62
71
  return [word];
63
72
  const chars = Array.from(word);
64
73
  const n = chars.length;
65
- // Find all vowel positions (base-char aware).
74
+ const lower = chars.map(baseChar).join("");
75
+ // A glide u carries no syllable: after q always (qui), and in the ngu
76
+ // cluster before a vowel (lin-gua, sán-guis) — but a u after plain g is a
77
+ // real vowel (e-xi-gu-us, ar-gu-it).
78
+ const isGlideU = (i) => {
79
+ if (baseChar(chars[i]) !== "u")
80
+ return false;
81
+ const prev = i > 0 ? baseChar(chars[i - 1]) : "";
82
+ if (prev === "q")
83
+ return true;
84
+ const prev2 = i > 1 ? baseChar(chars[i - 2]) : "";
85
+ return prev === "g" && prev2 === "n" && i + 1 < n && isVowelBase(chars[i + 1]);
86
+ };
87
+ // A consonantal i (the j sound) carries no syllable of its own — it opens
88
+ // the following vowel's: between real vowels (e-ius, ma-ior, al-le-lu-ia)
89
+ // or word-initial before a vowel (Ie-su, iu-bi-lá-te).
90
+ const isConsonantalI = (i) => {
91
+ if (baseChar(chars[i]) !== "i")
92
+ return false;
93
+ if (i + 1 >= n || !isVowelBase(chars[i + 1]))
94
+ return false;
95
+ if (i === 0)
96
+ return true;
97
+ return isVowelBase(chars[i - 1]) && !isGlideU(i - 1);
98
+ };
99
+ // Find all vowel positions (base-char aware; glides and consonantal i out).
66
100
  const vpos = [];
67
101
  for (let i = 0; i < n; i++) {
68
102
  if (isVowelBase(chars[i])) {
69
- const prev = i > 0 ? baseChar(chars[i - 1]) : "";
70
- if (baseChar(chars[i]) === "u" && prev === "q")
103
+ if (isGlideU(i) || isConsonantalI(i))
71
104
  continue;
72
105
  vpos.push(i);
73
106
  }
@@ -84,6 +117,10 @@ export function syllabifyWord(word) {
84
117
  const pair = baseChar(chars[v1]) + baseChar(chars[v2]);
85
118
  if (DIPHTHONGS.has(pair))
86
119
  continue;
120
+ // ui is a true diphthong only in the pronoun stems (cui, huic, and the
121
+ // cui- compounds); everywhere else it is hiatus (fu-it, su-i).
122
+ if (pair === "ui" && /^(cui|hui)/.test(lower))
123
+ continue;
87
124
  // All other adjacent vowels split in ecclesiastical Latin
88
125
  splits.add(v2);
89
126
  continue;
@@ -170,21 +207,57 @@ export function selectVowel(text) {
170
207
  const nfd = expanded.normalize("NFD");
171
208
  let firstVowel = "";
172
209
  let accentedVowel = "";
210
+ // Every vowel in order with the index it sat at, so the diphthong pass below
211
+ // can ask whether two of them are adjacent in the source.
212
+ const found = [];
173
213
  for (let i = 0; i < nfd.length; i++) {
174
214
  const ch = nfd[i];
175
215
  const base = ch.replace(/[\u0300-\u036f]/g, "").toLowerCase();
176
216
  if (!isVowelBase(base))
177
217
  continue;
178
218
  const v = base === "y" ? "i" : base;
219
+ found.push({ v, at: i });
179
220
  if (!firstVowel)
180
221
  firstVowel = v;
181
222
  if (!accentedVowel && i + 1 < nfd.length && ACCENTED.test(nfd[i + 1])) {
182
223
  accentedVowel = v;
183
224
  }
184
225
  }
185
- if (accentedVowel)
186
- return { vowel: accentedVowel, accent: true };
187
- return { vowel: firstVowel, accent: false };
226
+ const vowel = accentedVowel || firstVowel;
227
+ return { vowel, accent: Boolean(accentedVowel), diphthong: findDiphthong(nfd, found, vowel) };
228
+ }
229
+ /**
230
+ * The diphthong the SUNG vowel belongs to, or null. `vowel` is the nucleus — what
231
+ * a singer sustains — and a diphthong's second element is a late off-glide; this
232
+ * reports the pair so a renderer can flick toward it without re-deriving which
233
+ * pairs are real.
234
+ *
235
+ * It reuses the syllabifier's own DIPHTHONGS set, so "diphthong" here means
236
+ * exactly what it means when the word is split: ae · oe · au, plus ui in the
237
+ * cui/hui stems only. ei, ui elsewhere, and eu are hiatus (De-i, fu-it, de-us).
238
+ *
239
+ * THE ACCENT IS THE DISCRIMINATOR, which is why this cannot be recovered
240
+ * downstream from the lyric alone. `cae` is one syllable (nucleus a, glide e);
241
+ * `sa-é` is two — the same letters with the accent on the second, and no glide.
242
+ * A substring scan confuses the two, and also misreads `quae`, where `qu` is a
243
+ * consonantal glide: the nucleus is u, and the `ae` is not the sung pair.
244
+ */
245
+ function findDiphthong(nfd, found, vowel) {
246
+ const lower = [...nfd].map(baseChar).join("");
247
+ for (let i = 0; i < found.length - 1; i++) {
248
+ const a = found[i];
249
+ const b = found[i + 1];
250
+ // Adjacent in the source, and the pair's nucleus is the vowel we selected —
251
+ // anything else belongs to a different syllable.
252
+ if (b.at !== a.at + 1 || a.v !== vowel)
253
+ continue;
254
+ const pair = a.v + b.v;
255
+ if (DIPHTHONGS.has(pair))
256
+ return pair;
257
+ if (pair === "ui" && /^(cui|hui)/.test(lower))
258
+ return pair;
259
+ }
260
+ return null;
188
261
  }
189
262
  export function detectVowelAccent(text) {
190
263
  return selectVowel(text).accent;
@@ -1,12 +1,44 @@
1
1
  import type { Season, Grade, Feast } from "../cal/types.js";
2
2
  export type { Season, Grade, Feast };
3
- export type OfficeCode = "an" | "al" | "ca" | "co" | "gr" | "hy" | "in" | "of" | "ps" | "re" | "rb" | "se" | "tr" | "tp" | "or";
3
+ export type OfficeCode = "an" | "al" | "ca" | "co" | "gr" | "hy" | "in" | "of" | "ps" | "re" | "rb" | "se" | "tr" | "tp" | "or" | "im" | "pa" | "su";
4
4
  export type OrdinaryCode = "ky" | "gl" | "cr" | "sa" | "ag" | "be" | "it" | "as" | "va";
5
- export type ChantSource = "gr" | "lu" | "la" | "lh" | "am" | "nr";
5
+ /**
6
+ * The books a chant can come from. `source` is PROVENANCE, not an acquisition
7
+ * unit: a book appears here because some chant the liturgy asks for is found in
8
+ * it, not because the whole book ships.
9
+ *
10
+ * The first seven are the original corpus. The office books after them widened
11
+ * it: they carry antiphons and short responsories that fill weekday office
12
+ * slots — without them a matcher could only bind a slot to a chant the five
13
+ * extracted books happened to hold, which is why 41 Fridays had no Vespers
14
+ * ("Per singulos dies" lives in the Psalterium Monasticum).
15
+ *
16
+ * That widening brought in eight; six are gone again. am1, am2,
17
+ * am3, lr, ar1 and ar2 are bare transcriptions — no episema, no ictus,
18
+ * essentially no mora — so once the office matchers began preferring a
19
+ * rhythmically marked witness, every text they carried was better served by a
20
+ * book we already ship. tonus reads those marks for playback, so a bare chant is
21
+ * a worse copy, not a missing one.
22
+ *
23
+ * ams and psm stayed because they are fully marked, and psm holds "Per singulos
24
+ * dies" — the Friday Vespers antiphon the widening was for. Every chant tonus
25
+ * now ships carries rhythmic notation.
26
+ */
27
+ export type ChantSource = "gr" | "lu" | "la" | "lh" | "am" | "nr" | "ams" | "psm" | "cse" | "cot";
6
28
  export type CanonicalHour = "matutinum" | "laudes" | "prima" | "tertia" | "sexta" | "nona" | "vesperae" | "completorium";
7
- export declare const MODE_LABELS: Readonly<Record<string, string>>;
8
- export declare const OFFICE_LABELS: Readonly<Record<OfficeCode, string>>;
9
- export declare const ORDINARY_LABELS: Readonly<Record<string, string>>;
29
+ /** The eight canonical hours in the order they are sung, Matins first. The
30
+ * order is the content: a day's office read out of sequence is not the day's
31
+ * office. `officium` validates `hora` against this, so the list a caller reads
32
+ * and the check it must satisfy cannot drift apart. */
33
+ export declare const HORAE: readonly CanonicalHour[];
34
+ /** The keys cantus() accepts — the base set the day verbs extend. Lives here,
35
+ * cycle-free, so ordinary.ts can build its own key set without importing
36
+ * chant.ts (the two are an import cycle). */
37
+ export declare const CANTUS_QUERY_KEYS: Set<string>;
38
+ export declare const MODI: Readonly<Record<string, string>>;
39
+ export declare const OFFICIA: Readonly<Record<OfficeCode, string>>;
40
+ export declare const ORDINARIA: Readonly<Record<string, string>>;
41
+ export declare const KY_SOURCE: Chant["source"];
10
42
  export interface Chant {
11
43
  id: string;
12
44
  incipit: string;
@@ -27,7 +59,7 @@ export interface Chant {
27
59
  year: number | null;
28
60
  editor: string | null;
29
61
  scanSource?: string | null;
30
- code?: ChantSource | "user";
62
+ code?: ChantSource | "user" | "ky";
31
63
  };
32
64
  ordinary?: OrdinaryCode;
33
65
  ordinarium?: string;
@@ -61,12 +93,56 @@ export interface Corpus {
61
93
  editor: string | null;
62
94
  scanSource: string | null;
63
95
  count: number;
96
+ total: number | null;
97
+ unique: number | null;
98
+ shared: SharedCount[] | null;
99
+ genera: GenusCount[];
100
+ modes: ModeCount[];
101
+ /**
102
+ * What the book HOLDS, before the cut — the ledger of what was left behind.
103
+ * Same genera/modes shape as the shipped counts above, so an omission is
104
+ * visible rather than merely implied by a smaller number. `null` for a book
105
+ * outside GregoBase (nr, ky), the same "unmeasured, not zero" rule as
106
+ * `total`/`unique`/`shared`.
107
+ */
108
+ full: CorpusFullCount | null;
109
+ }
110
+ /** A book's pre-cut tally, in the shape `corpus()` reports the shipped one. */
111
+ export interface CorpusFullCount {
64
112
  total: number;
65
- unique: number;
66
- shared: SharedCount[];
67
113
  genera: GenusCount[];
68
114
  modes: ModeCount[];
69
115
  }
116
+ /**
117
+ * The whole shelf: every book's ledger, and the corpus-wide rollup.
118
+ * Returned by `corpus()` with no argument.
119
+ */
120
+ export interface CorpusLedger {
121
+ /**
122
+ * How many chants tonus holds — every chant it can name, counted once.
123
+ *
124
+ * This is THE number: distinct chants, so it does not move with how many
125
+ * books happen to print the same melody. Listings are `listings` below —
126
+ * a fact about the shelf, not about the repertoire.
127
+ */
128
+ count: number;
129
+ /**
130
+ * Book listings across the shelf: a chant printed in two books counts twice.
131
+ * `listings - count` is how much the books overlap. Secondary on purpose —
132
+ * it answers "how long is the shelf", not "how much chant is there".
133
+ */
134
+ listings: number;
135
+ /** Chants the books hold in total, before the cut. */
136
+ total: number;
137
+ genera: GenusCount[];
138
+ modes: ModeCount[];
139
+ /** Per book, in corpus order. */
140
+ books: Corpus[];
141
+ }
142
+ /** `corpus({ book })` — the query form; `corpus(code)` still works. */
143
+ export interface CorpusQuery {
144
+ book?: ChantSource;
145
+ }
70
146
  export interface CantusQuery {
71
147
  id?: string | string[];
72
148
  gabc?: string;
@@ -74,6 +150,37 @@ export interface CantusQuery {
74
150
  mode?: number | string | (number | string)[];
75
151
  office?: OfficeCode | OfficeCode[];
76
152
  source?: ChantSource | ChantSource[];
153
+ /**
154
+ * A part of the Mass ordinary — `"ky"` for the Kyries, `"gl"` the Glorias,
155
+ * and so on. This is the door to the Kyriale, which is addressable but not
156
+ * shelved (it is a partition of the Graduale, not a book), so it is absent
157
+ * from `source` and from an unfiltered search. Asking for an ordinary can
158
+ * only mean one thing, which is why it may reach where `mode` alone does not.
159
+ *
160
+ * For the setting a given DAY calls for, `ordinarium({ feast })` is the verb:
161
+ * it applies the Kyriale's own rubrics. This is the flat retrieval — every
162
+ * Kyrie in the book, whatever day would sing it.
163
+ */
164
+ ordinary?: OrdinaryCode | OrdinaryCode[];
165
+ /**
166
+ * Only chants ATTESTED by this year — the repertoire as of a date, the
167
+ * analogue of `festum({ before })`, and the two COMPOSE: a Feast resolved by
168
+ * `festum({ date, before })` carries the view, and every day verb (proprium,
169
+ * ordinarium, officium) serves under it without being told the year twice;
170
+ * an own `before` overrides the feast's. Only centuries wholly CLOSED by
171
+ * the year count as witnessed: `before: 1098` keeps what a manuscript of
172
+ * the 10th century or earlier already holds (see attest.ts for why).
173
+ *
174
+ * This is evidence, not existence: the date comes from CANTUS's manuscript
175
+ * index, so it is a terminus ante quem. A chant with no dated witness is
176
+ * excluded rather than assumed old — silence is not evidence of age.
177
+ * This is the ONE time argument: a `century` spelling is deliberately
178
+ * absent, because it was `before: N * 100` in different clothes — one
179
+ * cutoff internally, two spellings at the door (see attest.ts).
180
+ */
181
+ before?: number;
182
+ /** Only chants transmitted by this cursus; `both` always qualifies. */
183
+ cursus?: "monastic" | "secular";
77
184
  limit?: number;
78
185
  offset?: number;
79
186
  sort?: "incipit" | "mode" | "id";
@@ -86,13 +193,9 @@ export interface OrdinariumQuery extends CantusQuery {
86
193
  ordinary?: OrdinaryCode;
87
194
  mass?: number;
88
195
  }
89
- /** Which rite's Office to assemble. `romanum` (default) is the Tridentine Roman
90
- * cursus; `monasticum` is the Benedictine cursus (Antiphonale Monasticum). */
91
- export type Rite = "romanum" | "monasticum";
92
196
  export interface OfficiumQuery extends CantusQuery {
93
197
  feast?: Feast | Feast[];
94
198
  hora?: CanonicalHour;
95
- rite?: Rite;
96
199
  }
97
200
  export interface PsalmusQuery {
98
201
  psalm?: number | string;
@@ -1,9 +1,24 @@
1
+ /** The eight canonical hours in the order they are sung, Matins first. The
2
+ * order is the content: a day's office read out of sequence is not the day's
3
+ * office. `officium` validates `hora` against this, so the list a caller reads
4
+ * and the check it must satisfy cannot drift apart. */
5
+ export const HORAE = Object.freeze([
6
+ "matutinum", "laudes", "prima", "tertia", "sexta", "nona",
7
+ "vesperae", "completorium",
8
+ ]);
1
9
  // ── Display labels ──
2
- export const MODE_LABELS = Object.freeze({
10
+ /** The keys cantus() accepts — the base set the day verbs extend. Lives here,
11
+ * cycle-free, so ordinary.ts can build its own key set without importing
12
+ * chant.ts (the two are an import cycle). */
13
+ export const CANTUS_QUERY_KEYS = new Set([
14
+ "id", "gabc", "incipit", "mode", "office", "source", "limit", "offset", "sort",
15
+ "before", "cursus", "ordinary",
16
+ ]);
17
+ export const MODI = Object.freeze({
3
18
  "1": "Modus I", "2": "Modus II", "3": "Modus III", "4": "Modus IV",
4
19
  "5": "Modus V", "6": "Modus VI", "7": "Modus VII", "8": "Modus VIII",
5
20
  });
6
- export const OFFICE_LABELS = Object.freeze({
21
+ export const OFFICIA = Object.freeze({
7
22
  an: "Antiphona",
8
23
  al: "Alleluia",
9
24
  ca: "Canticum",
@@ -16,11 +31,15 @@ export const OFFICE_LABELS = Object.freeze({
16
31
  re: "Responsorium",
17
32
  rb: "Responsorium Breve",
18
33
  se: "Sequentia",
34
+ // Reported in the pre-cut tallies, not shipped — see OfficeCode above.
35
+ im: "Improperia", // the Good Friday Reproaches (Popule meus)
36
+ pa: "Antiphona Mariana", // Marian antiphons outside the office cycle
37
+ su: "Supplicatio", // litanies and supplications (the Easter Vigil litany)
19
38
  tr: "Tractus",
20
39
  tp: "Tonus Peregrinus",
21
40
  or: "Ordinarium",
22
41
  });
23
- export const ORDINARY_LABELS = Object.freeze({
42
+ export const ORDINARIA = Object.freeze({
24
43
  ky: "Kyrie eleison",
25
44
  gl: "Gloria",
26
45
  cr: "Credo",
@@ -31,4 +50,20 @@ export const ORDINARY_LABELS = Object.freeze({
31
50
  as: "Asperges",
32
51
  va: "Vidi aquam",
33
52
  });
53
+ // The Kyriale's bibliographic identity. NOT a ChantSource: `ky` is a partition
54
+ // of the Graduale, not a book of its own (chant.ts, CORPUS), so it is not a
55
+ // value `cantus({ source })` takes and not a row in the shelf. This record
56
+ // still rides every kyriale chant, because a chant should say which book it is
57
+ // printed in — and the Kyriale is what a singer would be holding.
58
+ // Hand-authored (the other books' SOURCE constants ride their generated data
59
+ // files; the kyriale's data file predates its book registration): the chants
60
+ // are the Kyriale section of the 1961 Solesmes Graduale Romanum, extracted
61
+ // from GregoBase GR source pages. Shared by the corpus surface (chant.ts) and
62
+ // the ordinary engine so a kyriale chant carries ONE identity everywhere.
63
+ export const KY_SOURCE = Object.freeze({
64
+ book: "Kyriale (Graduale Romanum)",
65
+ year: 1961,
66
+ editor: "Solesmes",
67
+ code: "ky",
68
+ });
34
69
  //# sourceMappingURL=types.js.map
@@ -63,6 +63,10 @@ export function buildHarmonia(input, opts = {}) {
63
63
  if (cosmosArray.length === 0) {
64
64
  throw new RangeError("harmonia requires at least one Cosmos");
65
65
  }
66
+ for (const c of cosmosArray) {
67
+ if (!c || typeof c !== "object" || !Array.isArray(c.bodies))
68
+ throw new Error("harmonia: input must be a Cosmos (from tonus.caelum) — its bodies are what get voiced");
69
+ }
66
70
  const temper = opts.temperamentum ?? buildTemper();
67
71
  const scale = resolveScale(temper);
68
72
  const doctrinaKey = opts.doctrina ?? "boethius";
@@ -7,7 +7,9 @@
7
7
  // as [numerator, denominator] pairs.
8
8
  //
9
9
  // All v1 doctrinae are geocentric — Earth is the silent listener at the
10
- // center, not a voiced body (except Pliny, where Earth is a boundary tone).
10
+ // center, not a voiced body. Pliny's table does assign Earth a boundary tone
11
+ // (proslambanomenos, kept below as documentation), but Earth carries no
12
+ // classical vowel, so the voicing engine leaves it silent there too.
11
13
  // The Sun holds the mese position in all four systems.
12
14
  //
13
15
  // The ratios are reconstructed from the primary texts [biblio:
@@ -11,6 +11,9 @@ export interface HarmonyTabulaRow {
11
11
  oct: number;
12
12
  spn: string;
13
13
  hz: number;
14
+ /** The doctrina's ratio against the mese, [num, den] — what the sphere IS
15
+ * in the scheme, of which spn and hz are the sounding. */
16
+ ratio: readonly [number, number];
14
17
  presence: number;
15
18
  motion: number;
16
19
  velocity: number;
@@ -15,6 +15,7 @@ export function computeHarmonyTabula(bodies, aspects) {
15
15
  oct: b.nota.pitch.oct,
16
16
  spn: b.nota.pitch.spn,
17
17
  hz: b.nota.pitch.hz,
18
+ ratio: b.ratio,
18
19
  presence: b.presence,
19
20
  motion: b.motion,
20
21
  velocity: b.nota.performance.velocity,
@@ -13,6 +13,10 @@ export interface VoicedBody extends Body {
13
13
  presence: number;
14
14
  motion: number;
15
15
  greekName: string;
16
+ /** The doctrina's own ratio for this sphere, [num, den] against the mese.
17
+ * The pitch is DERIVED from it, so this is the primary datum and the note
18
+ * name is the reading — a caller comparing doctrinae wants the fraction. */
19
+ ratio: readonly [number, number];
16
20
  vowel: PlanetVowel;
17
21
  }
18
22
  export declare function voiceBodies(bodies: Body[], doctrina: Doctrina, scale: Scale): VoicedBody[];
@@ -5,9 +5,8 @@ import { computePresence, computeMotion } from "./presence.js";
5
5
  // reference. Ratios in the doctrina are multiplied by this anchor to produce Hz.
6
6
  const ANCHOR_MIDI = 69;
7
7
  function voiceOne(body, voice, vowel, scale) {
8
- const pliny = voice.greekName === "proslambanomenos" && body.name === "Earth";
9
- const presence = pliny ? 1 : computePresence(body);
10
- const motion = pliny ? 0 : computeMotion(body);
8
+ const presence = computePresence(body);
9
+ const motion = computeMotion(body);
11
10
  // Direct Hz from anchor (A4) and doctrina ratio — bypass scale quantization
12
11
  // so the ratio's pure Hz relationship is preserved.
13
12
  const ratioVal = voice.ratio[0] / voice.ratio[1];
@@ -28,6 +27,7 @@ function voiceOne(body, voice, vowel, scale) {
28
27
  presence,
29
28
  motion,
30
29
  greekName: voice.greekName,
30
+ ratio: voice.ratio,
31
31
  vowel,
32
32
  };
33
33
  }
@@ -41,9 +41,13 @@ export function voiceBodies(bodies, doctrina, scale) {
41
41
  const voice = voiceByBody.get(body.name);
42
42
  if (!voice)
43
43
  continue; // body not part of this doctrina (e.g. Earth in Boethius)
44
+ // Only the seven vowel-bearing planets voice. Earth stays silent even
45
+ // under Pliny, whose table assigns it proslambanomenos — the ratio is
46
+ // kept in the doctrina as documentation, but no classical vowel exists
47
+ // for Earth, and tonus does not invent one.
44
48
  const vowel = PLANET_VOWELS[body.name];
45
49
  if (!vowel)
46
- continue; // body has no classical vowel mapping (e.g. Earth, FixedStars)
50
+ continue;
47
51
  result.push(voiceOne(body, voice, vowel, scale));
48
52
  }
49
53
  return result;
@@ -84,6 +84,10 @@ export function computeImprint(phrases, scale, opts = {}) {
84
84
  const cadenceNotes = opts.cadenceNotes;
85
85
  const pcCounts = new Array(12).fill(0);
86
86
  let total = 0;
87
+ // For the tessitura signal: the mean MIDI of every note, and the last note.
88
+ let midiSum = 0;
89
+ let noteCount = 0;
90
+ let lastNote = null;
87
91
  for (let pi = 0; pi < phrases.length; pi++) {
88
92
  const phrase = phrases[pi];
89
93
  for (let si = 0; si < phrase.syllables.length; si++) {
@@ -97,6 +101,9 @@ export function computeImprint(phrases, scale, opts = {}) {
97
101
  w *= CADENCE_WEIGHT;
98
102
  pcCounts[note.pitch.pc] += w;
99
103
  total += w;
104
+ midiSum += note.pitch.midi;
105
+ noteCount++;
106
+ lastNote = { pc: note.pitch.pc, midi: note.pitch.midi };
100
107
  }
101
108
  }
102
109
  }
@@ -105,11 +112,17 @@ export function computeImprint(phrases, scale, opts = {}) {
105
112
  pcDistribution[pc] = total > 0 ? pcCounts[pc] / total : 0;
106
113
  }
107
114
  const firstNotePc = phrases[0]?.syllables[0]?.notes[0]?.pitch.pc;
115
+ // Tessitura = the melody's mean height above where it comes to rest.
116
+ const tessitura = lastNote && noteCount > 0 ? midiSum / noteCount - lastNote.midi : undefined;
108
117
  return {
109
118
  pcDistribution,
110
119
  attractors: computeAttractors(pcDistribution, scale),
111
120
  vowelAttractors: computeVowelAttractors(phrases, scale),
112
- modalAffinity: computeModalAffinity(pcDistribution, firstNotePc),
121
+ modalAffinity: computeModalAffinity(pcDistribution, {
122
+ firstNotePc,
123
+ lastNotePc: lastNote?.pc,
124
+ tessitura,
125
+ }),
113
126
  };
114
127
  }
115
128
  /** Build an Imprint from voiced planetary bodies (presence-weighted pc counts). */
@@ -34,7 +34,7 @@ export const ORBITAL_ELEMENTS = new Map([
34
34
  datasets: [
35
35
  [
36
36
  [0.72333566, 0.0000039],
37
- [0.00676399, -0.00005107],
37
+ [0.00677672, -0.00004107],
38
38
  [3.39467605, -0.0007889],
39
39
  [181.9790995, 58517.81538729],
40
40
  [131.60246718, 0.00268329],
@@ -42,7 +42,7 @@ export const ORBITAL_ELEMENTS = new Map([
42
42
  ],
43
43
  [
44
44
  [0.72332102, -0.00000026],
45
- [-0.00005107, 0.01673163],
45
+ [0.00676399, -0.00005107],
46
46
  [3.39777545, 0.00043494],
47
47
  [181.9797085, 58517.8156026],
48
48
  [131.76755713, 0.05679648],
@@ -68,7 +68,7 @@ export const ORBITAL_ELEMENTS = new Map([
68
68
  [0, 0],
69
69
  ],
70
70
  [
71
- [1.00000018, 1.52371243],
71
+ [1.00000018, -0.00000003],
72
72
  [0.01673163, -0.00003661],
73
73
  [-0.00054346, -0.01337178],
74
74
  [100.46691572, 35999.37306329],
@@ -129,7 +129,7 @@ export const ORBITAL_ELEMENTS = new Map([
129
129
  [14.27495244, 0.18199196],
130
130
  [100.29282654, 0.13024619],
131
131
  ],
132
- [0.00012452, 0.0606406, -0.35635438, 38.35125],
132
+ [-0.00012452, 0.0606406, -0.35635438, 38.35125],
133
133
  ],
134
134
  radius: 69911,
135
135
  rotation_period: 0.41354,