tonus 0.1.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 (141) hide show
  1. package/BIBLIOGRAPHY.md +99 -0
  2. package/LICENSE +29 -0
  3. package/README.md +108 -0
  4. package/dist/data/cal.d.ts +10 -0
  5. package/dist/data/cal.js +3862 -0
  6. package/dist/data/commune.d.ts +17 -0
  7. package/dist/data/commune.js +1333 -0
  8. package/dist/data/gr.d.ts +5 -0
  9. package/dist/data/gr.js +13449 -0
  10. package/dist/data/kyriale.d.ts +11 -0
  11. package/dist/data/kyriale.js +971 -0
  12. package/dist/data/la.d.ts +5 -0
  13. package/dist/data/la.js +14229 -0
  14. package/dist/data/lh.d.ts +5 -0
  15. package/dist/data/lh.js +3619 -0
  16. package/dist/data/lu.d.ts +5 -0
  17. package/dist/data/lu.js +23779 -0
  18. package/dist/data/masses.d.ts +18 -0
  19. package/dist/data/masses.js +297 -0
  20. package/dist/data/office-roman.d.ts +19 -0
  21. package/dist/data/office-roman.js +13792 -0
  22. package/dist/data/office.d.ts +12 -0
  23. package/dist/data/office.js +13052 -0
  24. package/dist/data/propers.d.ts +13 -0
  25. package/dist/data/propers.js +7584 -0
  26. package/dist/data/psalms.d.ts +4 -0
  27. package/dist/data/psalms.js +10 -0
  28. package/dist/data/psalms.json +22918 -0
  29. package/dist/data/tones.d.ts +20 -0
  30. package/dist/data/tones.js +153 -0
  31. package/dist/data/types.d.ts +3 -0
  32. package/dist/data/types.js +2 -0
  33. package/dist/engines/cal/calendar.d.ts +21 -0
  34. package/dist/engines/cal/calendar.js +265 -0
  35. package/dist/engines/cal/date.d.ts +31 -0
  36. package/dist/engines/cal/date.js +141 -0
  37. package/dist/engines/cal/types.d.ts +66 -0
  38. package/dist/engines/cal/types.js +189 -0
  39. package/dist/engines/chant/chant.d.ts +10 -0
  40. package/dist/engines/chant/chant.js +135 -0
  41. package/dist/engines/chant/hour.d.ts +8 -0
  42. package/dist/engines/chant/hour.js +135 -0
  43. package/dist/engines/chant/intone.d.ts +8 -0
  44. package/dist/engines/chant/intone.js +84 -0
  45. package/dist/engines/chant/ordinary.d.ts +7 -0
  46. package/dist/engines/chant/ordinary.js +232 -0
  47. package/dist/engines/chant/propers.d.ts +8 -0
  48. package/dist/engines/chant/propers.js +107 -0
  49. package/dist/engines/chant/psalm.d.ts +7 -0
  50. package/dist/engines/chant/psalm.js +60 -0
  51. package/dist/engines/chant/syllabify.d.ts +20 -0
  52. package/dist/engines/chant/syllabify.js +192 -0
  53. package/dist/engines/chant/types.d.ts +76 -0
  54. package/dist/engines/chant/types.js +34 -0
  55. package/dist/engines/epoch.d.ts +2 -0
  56. package/dist/engines/epoch.js +14 -0
  57. package/dist/engines/harmonia/api.d.ts +35 -0
  58. package/dist/engines/harmonia/api.js +90 -0
  59. package/dist/engines/harmonia/aspects.d.ts +8 -0
  60. package/dist/engines/harmonia/aspects.js +15 -0
  61. package/dist/engines/harmonia/data/doctrines.d.ts +16 -0
  62. package/dist/engines/harmonia/data/doctrines.js +154 -0
  63. package/dist/engines/harmonia/data/vowels.d.ts +10 -0
  64. package/dist/engines/harmonia/data/vowels.js +21 -0
  65. package/dist/engines/harmonia/presence.d.ts +13 -0
  66. package/dist/engines/harmonia/presence.js +48 -0
  67. package/dist/engines/harmonia/tabula.d.ts +28 -0
  68. package/dist/engines/harmonia/tabula.js +32 -0
  69. package/dist/engines/harmonia/voice.d.ts +19 -0
  70. package/dist/engines/harmonia/voice.js +51 -0
  71. package/dist/engines/imprint.d.ts +30 -0
  72. package/dist/engines/imprint.js +152 -0
  73. package/dist/engines/planet/appearance.d.ts +40 -0
  74. package/dist/engines/planet/appearance.js +84 -0
  75. package/dist/engines/planet/aspects.d.ts +5 -0
  76. package/dist/engines/planet/aspects.js +41 -0
  77. package/dist/engines/planet/math.d.ts +13 -0
  78. package/dist/engines/planet/math.js +56 -0
  79. package/dist/engines/planet/orbital.d.ts +25 -0
  80. package/dist/engines/planet/orbital.js +223 -0
  81. package/dist/engines/planet/planet.d.ts +13 -0
  82. package/dist/engines/planet/planet.js +198 -0
  83. package/dist/engines/planet/position.d.ts +62 -0
  84. package/dist/engines/planet/position.js +156 -0
  85. package/dist/engines/planet/types.d.ts +61 -0
  86. package/dist/engines/planet/types.js +14 -0
  87. package/dist/engines/score/api.d.ts +54 -0
  88. package/dist/engines/score/api.js +87 -0
  89. package/dist/engines/score/articulation.d.ts +6 -0
  90. package/dist/engines/score/articulation.js +112 -0
  91. package/dist/engines/score/emitters/midi.d.ts +65 -0
  92. package/dist/engines/score/emitters/midi.js +158 -0
  93. package/dist/engines/score/emitters/musicxml.d.ts +18 -0
  94. package/dist/engines/score/emitters/musicxml.js +166 -0
  95. package/dist/engines/score/infer.d.ts +4 -0
  96. package/dist/engines/score/infer.js +77 -0
  97. package/dist/engines/score/ir.d.ts +4 -0
  98. package/dist/engines/score/ir.js +177 -0
  99. package/dist/engines/score/meta.d.ts +19 -0
  100. package/dist/engines/score/meta.js +34 -0
  101. package/dist/engines/score/neume.d.ts +3 -0
  102. package/dist/engines/score/neume.js +26 -0
  103. package/dist/engines/score/parse.d.ts +3 -0
  104. package/dist/engines/score/parse.js +359 -0
  105. package/dist/engines/score/phrasing.d.ts +24 -0
  106. package/dist/engines/score/phrasing.js +257 -0
  107. package/dist/engines/score/prosody.d.ts +35 -0
  108. package/dist/engines/score/prosody.js +109 -0
  109. package/dist/engines/score/tabula.d.ts +70 -0
  110. package/dist/engines/score/tabula.js +109 -0
  111. package/dist/engines/score/types.d.ts +159 -0
  112. package/dist/engines/score/types.js +2 -0
  113. package/dist/engines/temper/api.d.ts +60 -0
  114. package/dist/engines/temper/api.js +130 -0
  115. package/dist/engines/temper/data/constants.d.ts +27 -0
  116. package/dist/engines/temper/data/constants.js +150 -0
  117. package/dist/engines/temper/data/guido.d.ts +14 -0
  118. package/dist/engines/temper/data/guido.js +29 -0
  119. package/dist/engines/temper/data/modes.d.ts +38 -0
  120. package/dist/engines/temper/data/modes.js +158 -0
  121. package/dist/engines/temper/gabc.d.ts +5 -0
  122. package/dist/engines/temper/gabc.js +53 -0
  123. package/dist/engines/temper/gamut.d.ts +9 -0
  124. package/dist/engines/temper/gamut.js +24 -0
  125. package/dist/engines/temper/guido.d.ts +16 -0
  126. package/dist/engines/temper/guido.js +48 -0
  127. package/dist/engines/temper/interval.d.ts +15 -0
  128. package/dist/engines/temper/interval.js +31 -0
  129. package/dist/engines/temper/modes.d.ts +6 -0
  130. package/dist/engines/temper/modes.js +13 -0
  131. package/dist/engines/temper/neume.d.ts +14 -0
  132. package/dist/engines/temper/neume.js +59 -0
  133. package/dist/engines/temper/pitch.d.ts +40 -0
  134. package/dist/engines/temper/pitch.js +129 -0
  135. package/dist/engines/temper/scale.d.ts +37 -0
  136. package/dist/engines/temper/scale.js +217 -0
  137. package/dist/engines/temper/step.d.ts +23 -0
  138. package/dist/engines/temper/step.js +53 -0
  139. package/dist/index.d.ts +40 -0
  140. package/dist/index.js +27 -0
  141. package/package.json +60 -0
@@ -0,0 +1,232 @@
1
+ // ---------------------------------------------------------------------------
2
+ // engines/chant/ordinary — Mass ordinary (kyriale) selection
3
+ // ---------------------------------------------------------------------------
4
+ import { MASSES, AD_LIB } from "../../data/masses.js";
5
+ import { KYRIALE } from "../../data/kyriale.js";
6
+ import { MODE_LABELS, ORDINARY_LABELS, } from "./types.js";
7
+ import { gradeOrder, PENITENTIAL_SEASONS, } from "../cal/types.js";
8
+ // A "high feast" (Duplex II classis or above) prefers the solemn kyriale
9
+ // masses 1–9. Threshold expressed against GRADE_ORDER, not a magic number.
10
+ function isHighFeast(grade) {
11
+ return gradeOrder(grade) <= gradeOrder("duplex-ii");
12
+ }
13
+ const ORDINARY_OFFICES = new Set(Object.keys(ORDINARY_LABELS));
14
+ const MODE_PAIRS = [[1, 2], [3, 4], [5, 6], [7, 8]];
15
+ const CREDO_PRIORITY = ["IV", "III", "I", "II", "V", "VI"];
16
+ function pairedMode(mode) {
17
+ const pair = MODE_PAIRS.find((p) => p.includes(mode));
18
+ return pair ? (pair.find((m) => m !== mode) ?? null) : null;
19
+ }
20
+ function resolveMasses(feast) {
21
+ const resolved = feast.masses
22
+ .map((num) => MASSES.get(num) ?? null)
23
+ .filter((m) => m !== null);
24
+ if (resolved.length)
25
+ return resolved;
26
+ return [feast.marian ? AD_LIB.bvm : AD_LIB.standard];
27
+ }
28
+ function entriesForOffice(office, massNumbers) {
29
+ const byMass = KYRIALE.filter((e) => e.office === office && e.mass != null && massNumbers.includes(e.mass));
30
+ if (byMass.length)
31
+ return byMass;
32
+ return KYRIALE.filter((e) => e.office === office && e.mass == null);
33
+ }
34
+ function selectBestChant(entries, filterMode, highFeast, massNumbers) {
35
+ if (!entries.length)
36
+ return null;
37
+ const modeStr = filterMode != null ? String(filterMode) : null;
38
+ let candidates = modeStr
39
+ ? entries.filter((e) => e.mode === modeStr)
40
+ : entries;
41
+ if (!candidates.length && modeStr) {
42
+ const paired = pairedMode(filterMode);
43
+ if (paired)
44
+ candidates = entries.filter((e) => e.mode === String(paired));
45
+ }
46
+ if (!candidates.length)
47
+ candidates = entries;
48
+ if (highFeast && candidates.length > 1) {
49
+ const preferred = candidates.filter((c) => c.mass != null && c.mass >= 1 && c.mass <= 9);
50
+ if (preferred.length)
51
+ candidates = preferred;
52
+ }
53
+ return candidates[0];
54
+ }
55
+ function allowedCredos(masses) {
56
+ const set = new Set();
57
+ for (const m of masses)
58
+ for (const c of m.credos)
59
+ set.add(c);
60
+ return CREDO_PRIORITY.filter((c) => set.has(c));
61
+ }
62
+ function selectCredoCode(feast, allowed) {
63
+ if (!allowed.length)
64
+ return null;
65
+ const { season, weekday, marian, apostolic } = feast;
66
+ const isSunday = weekday === 0;
67
+ if (isSunday && ["adv", "quadp", "quad", "nat"].includes(season) && allowed.includes("IV"))
68
+ return "IV";
69
+ if (isSunday && season === "pasc" && allowed.includes("III"))
70
+ return "III";
71
+ if (isSunday && ["epi", "pent"].includes(season) && allowed.includes("I"))
72
+ return "I";
73
+ if (apostolic && allowed.includes("III"))
74
+ return "III";
75
+ if (marian && allowed.includes("IV"))
76
+ return "IV";
77
+ return allowed[0];
78
+ }
79
+ function entryToOrdinaryChant(entry) {
80
+ const ordinary = ORDINARY_OFFICES.has(entry.office)
81
+ ? entry.office
82
+ : "ky";
83
+ return {
84
+ id: entry.id,
85
+ incipit: entry.incipit,
86
+ gabc: entry.gabc,
87
+ office: "or",
88
+ genus: "Ordinarium",
89
+ mode: entry.mode ? String(entry.mode) : null,
90
+ modus: entry.mode ? (MODE_LABELS[String(entry.mode)] ?? null) : null,
91
+ pages: [],
92
+ source: { book: "Graduale Romanum", year: 1961, editor: "Solesmes", code: "gr" },
93
+ ordinary,
94
+ ordinarium: ORDINARY_LABELS[ordinary] ?? entry.incipit,
95
+ mass: entry.mass ?? 0,
96
+ };
97
+ }
98
+ // Maundy Thursday (In Cena Domini) is a Triduum exception: it retains a full
99
+ // Mass with the Gloria (rung with bells, which then fall silent until the
100
+ // Easter Vigil) despite Lent's penitential omission and the Triduum's
101
+ // otherwise empty ordinary. The Credo and the Sunday sprinkle rite are not
102
+ // part of this evening Mass. See docs/chant.md.
103
+ const MAUNDY_THURSDAY_ID = "Quad6-4";
104
+ // The feast carries no numbered Kyriale mass of its own (masses: []); as a
105
+ // paschally-adjacent solemnity it draws on Mass I (Lux et origo) — the same
106
+ // mass the Easter Vigil borrows, so both Triduum Masses share a setting.
107
+ const MAUNDY_THURSDAY_MASS = 1;
108
+ function ordinaryForFeast(feast, pinMass, filterMode) {
109
+ const isMaundyThursday = feast.id === MAUNDY_THURSDAY_ID;
110
+ // The Triduum has no Mass-ordinary cycle (Good Friday has no Mass; the
111
+ // Vigil's ordinary belongs to Easter). Maundy Thursday is the exception —
112
+ // it keeps its Mass. An explicitly pinned mass also overrides.
113
+ if (feast.grade === "triduum" && pinMass == null && !isMaundyThursday)
114
+ return [];
115
+ const resolvedMass = pinMass ?? (isMaundyThursday ? MAUNDY_THURSDAY_MASS : undefined);
116
+ const masses = resolvedMass != null
117
+ ? (() => { const e = MASSES.get(resolvedMass); return e ? [e] : []; })()
118
+ : resolveMasses(feast);
119
+ const massNumbers = masses.map((m) => m.mass);
120
+ const mode = filterMode ?? null;
121
+ const highFeast = isHighFeast(feast.grade);
122
+ const pick = (office) => {
123
+ const entries = entriesForOffice(office, massNumbers);
124
+ const best = selectBestChant(entries, mode, highFeast, massNumbers);
125
+ return best ? entryToOrdinaryChant(best) : null;
126
+ };
127
+ const results = [];
128
+ const ky = pick("ky");
129
+ if (ky)
130
+ results.push(ky);
131
+ // Gloria is omitted in penitential seasons (Advent, Septuagesima, Lent).
132
+ // Maundy Thursday keeps it — its Gloria is a deliberate breach of Lenten
133
+ // austerity, sung with the bells before they fall silent.
134
+ const glOmitted = PENITENTIAL_SEASONS.has(feast.season) && !isMaundyThursday;
135
+ if (!glOmitted) {
136
+ const gl = pick("gl");
137
+ if (gl)
138
+ results.push(gl);
139
+ }
140
+ // Credo — In Cena Domini's Mass has no Creed.
141
+ if (!isMaundyThursday) {
142
+ const allowed = allowedCredos(masses);
143
+ const credoCode = selectCredoCode(feast, allowed);
144
+ if (credoCode) {
145
+ const credoEntries = KYRIALE.filter((e) => e.office === "cr");
146
+ const named = credoEntries.find((e) => e.incipit.includes(credoCode));
147
+ const best = named ?? selectBestChant(credoEntries, mode, highFeast, massNumbers);
148
+ if (best) {
149
+ const cr = entryToOrdinaryChant(best);
150
+ cr.ordinarium = `Credo ${credoCode}`;
151
+ results.push(cr);
152
+ }
153
+ }
154
+ }
155
+ const sa = pick("sa");
156
+ if (sa)
157
+ results.push(sa);
158
+ const ag = pick("ag");
159
+ if (ag)
160
+ results.push(ag);
161
+ if (glOmitted) {
162
+ const be = pick("be");
163
+ if (be)
164
+ results.push(be);
165
+ }
166
+ else {
167
+ const it = pick("it");
168
+ if (it)
169
+ results.push(it);
170
+ }
171
+ // Sprinkle rite: Vidi aquam in Paschaltide (through the Pentecost octave),
172
+ // Asperges otherwise. It precedes the principal Sunday Mass only — not the
173
+ // evening Mass of In Cena Domini.
174
+ if (!isMaundyThursday) {
175
+ const sprinkleType = feast.season === "pasc" ? "va" : "as";
176
+ const sprinkleEntries = entriesForOffice(sprinkleType, massNumbers);
177
+ const sprinkleBest = selectBestChant(sprinkleEntries, mode, highFeast, massNumbers);
178
+ if (sprinkleBest)
179
+ results.push(entryToOrdinaryChant(sprinkleBest));
180
+ }
181
+ return results;
182
+ }
183
+ function toArray(v) {
184
+ if (v === undefined)
185
+ return undefined;
186
+ return Array.isArray(v) ? v : [v];
187
+ }
188
+ /**
189
+ * Mass ordinary retrieval (`tonus.ordinarium`) from the Kyriale. A feast
190
+ * drives mass selection; `mass` pins a kyriale number directly.
191
+ */
192
+ export function getOrdinary(query) {
193
+ if (!query || Object.keys(query).length === 0)
194
+ return [];
195
+ const feasts = toArray(query.feast);
196
+ const filterMode = query.mode != null ? Number(query.mode) : undefined;
197
+ let results;
198
+ if (feasts) {
199
+ results = feasts.flatMap((f) => ordinaryForFeast(f, query.mass, filterMode));
200
+ }
201
+ else if (query.mass != null || query.ordinary) {
202
+ // Direct kyriale query without feast context
203
+ let entries = KYRIALE.slice();
204
+ if (query.mass != null)
205
+ entries = entries.filter((e) => e.mass === query.mass);
206
+ if (query.ordinary)
207
+ entries = entries.filter((e) => e.office === query.ordinary);
208
+ if (filterMode != null)
209
+ entries = entries.filter((e) => e.mode === String(filterMode));
210
+ const offset = Math.max(0, query.offset ?? 0);
211
+ const limit = query.limit == null ? entries.length : Math.max(0, query.limit);
212
+ results = entries.slice(offset, offset + limit).map(entryToOrdinaryChant);
213
+ }
214
+ else {
215
+ return [];
216
+ }
217
+ // Apply remaining CantusQuery filters
218
+ if (query.incipit) {
219
+ const needle = query.incipit.toLowerCase();
220
+ results = results.filter((c) => c.incipit.toLowerCase().includes(needle));
221
+ }
222
+ if (query.id) {
223
+ const ids = new Set(toArray(query.id));
224
+ results = results.filter((c) => ids.has(c.id));
225
+ }
226
+ if (query.source) {
227
+ const sources = new Set(toArray(query.source));
228
+ results = results.filter((c) => c.source.code != null && sources.has(c.source.code));
229
+ }
230
+ return results;
231
+ }
232
+ //# sourceMappingURL=ordinary.js.map
@@ -0,0 +1,8 @@
1
+ import type { Chant, PropriumQuery } from "./types.js";
2
+ /**
3
+ * Mass proper retrieval (`tonus.proprium`): Introitus, Graduale,
4
+ * Alleluia/Tractus, Offertorium, Communio. A feast narrows the result;
5
+ * feasts without a dedicated proper fall back to the Commune Sanctorum.
6
+ */
7
+ export declare function getPropers(query?: PropriumQuery): Chant[];
8
+ //# sourceMappingURL=propers.d.ts.map
@@ -0,0 +1,107 @@
1
+ // ---------------------------------------------------------------------------
2
+ // engines/chant/propers — Mass proper lookup
3
+ // ---------------------------------------------------------------------------
4
+ import { resolveChant } from "./chant.js";
5
+ import { temporaSundayId } from "../cal/date.js";
6
+ import { PROPERS } from "../../data/propers.js";
7
+ import { COMMUNE_PROPERS, FEAST_COMMUNE } from "../../data/commune.js";
8
+ let _byFeastId = null;
9
+ function byFeastId() {
10
+ if (!_byFeastId)
11
+ _byFeastId = new Map(PROPERS.map((p) => [p.feastId, p]));
12
+ return _byFeastId;
13
+ }
14
+ let _communeByFeast = null;
15
+ function communeByFeast() {
16
+ if (!_communeByFeast)
17
+ _communeByFeast = new Map(FEAST_COMMUNE.map((f) => [f.feastId, f.commune]));
18
+ return _communeByFeast;
19
+ }
20
+ let _communePropers = null;
21
+ function communePropers() {
22
+ if (!_communePropers)
23
+ _communePropers = new Map(COMMUNE_PROPERS.map((c) => [c.commune, c]));
24
+ return _communePropers;
25
+ }
26
+ const PROPER_SLOTS = ["in", "gr", "al", "tr", "of", "co"];
27
+ function resolveProperChants(feastId) {
28
+ const map = byFeastId();
29
+ const proper = map.get(feastId) ?? null;
30
+ const sunday = temporaSundayId(feastId);
31
+ const seasonProper = sunday ? (map.get(sunday) ?? null) : null;
32
+ const commune = communeByFeast().get(feastId);
33
+ const communeProper = commune ? (communePropers().get(commune) ?? null) : null;
34
+ const results = [];
35
+ for (const slot of PROPER_SLOTS) {
36
+ const id = proper?.[slot] ?? seasonProper?.[slot] ?? communeProper?.[slot] ?? null;
37
+ const chant = resolveChant(id);
38
+ if (chant)
39
+ results.push(chant);
40
+ }
41
+ return results;
42
+ }
43
+ function toFeastArray(v) {
44
+ if (v === undefined)
45
+ return undefined;
46
+ return Array.isArray(v) ? v : [v];
47
+ }
48
+ function toArray(v) {
49
+ if (v === undefined)
50
+ return undefined;
51
+ return Array.isArray(v) ? v : [v];
52
+ }
53
+ /**
54
+ * Mass proper retrieval (`tonus.proprium`): Introitus, Graduale,
55
+ * Alleluia/Tractus, Offertorium, Communio. A feast narrows the result;
56
+ * feasts without a dedicated proper fall back to the Commune Sanctorum.
57
+ */
58
+ export function getPropers(query) {
59
+ if (!query || Object.keys(query).length === 0)
60
+ return [];
61
+ const feasts = toFeastArray(query.feast);
62
+ let results;
63
+ if (feasts) {
64
+ results = feasts.flatMap((f) => resolveProperChants(f.id));
65
+ }
66
+ else {
67
+ // No feast filter — resolve all propers
68
+ results = PROPERS.flatMap((p) => resolveProperChants(p.feastId));
69
+ }
70
+ const offices = toArray(query.office);
71
+ if (offices) {
72
+ const set = new Set(offices);
73
+ results = results.filter((c) => set.has(c.office));
74
+ }
75
+ const modes = toArray(query.mode);
76
+ if (modes) {
77
+ const set = new Set(modes.map(String));
78
+ results = results.filter((c) => c.mode != null && set.has(c.mode));
79
+ }
80
+ const sources = toArray(query.source);
81
+ if (sources) {
82
+ const set = new Set(sources);
83
+ results = results.filter((c) => c.source.code != null && set.has(c.source.code));
84
+ }
85
+ if (query.incipit) {
86
+ const needle = query.incipit.toLowerCase();
87
+ results = results.filter((c) => c.incipit.toLowerCase().includes(needle));
88
+ }
89
+ if (query.id) {
90
+ const ids = toArray(query.id);
91
+ const set = new Set(ids);
92
+ results = results.filter((c) => set.has(c.id));
93
+ }
94
+ const sort = query.sort ?? "incipit";
95
+ results.sort((a, b) => {
96
+ if (sort === "id")
97
+ return a.id.localeCompare(b.id);
98
+ if (sort === "mode")
99
+ return (String(a.mode ?? "").localeCompare(String(b.mode ?? "")) ||
100
+ a.incipit.localeCompare(b.incipit));
101
+ return a.incipit.localeCompare(b.incipit);
102
+ });
103
+ const offset = Math.max(0, query.offset ?? 0);
104
+ const limit = query.limit == null ? results.length : Math.max(0, query.limit);
105
+ return results.slice(offset, offset + limit);
106
+ }
107
+ //# sourceMappingURL=propers.js.map
@@ -0,0 +1,7 @@
1
+ import { type Chant, type PsalmusQuery } from "./types.js";
2
+ /**
3
+ * Psalm and canticle retrieval (`tonus.psalmus`) from the Psalterium,
4
+ * intoned to the psalm tones (modes 1-8 plus tonus peregrinus) as GABC.
5
+ */
6
+ export declare function getPsalm(query?: PsalmusQuery): Chant[];
7
+ //# sourceMappingURL=psalm.d.ts.map
@@ -0,0 +1,60 @@
1
+ // ---------------------------------------------------------------------------
2
+ // engines/chant/psalm — psalm and canticle retrieval as intoned Chant[]
3
+ // ---------------------------------------------------------------------------
4
+ import { PSALMS } from "../../data/psalms.js";
5
+ import { intone } from "./intone.js";
6
+ import { MODE_LABELS } from "./types.js";
7
+ const CANTICLE_NAMES = {
8
+ benedictus: 231,
9
+ magnificat: 234,
10
+ "nunc dimittis": 227,
11
+ "te deum": 240,
12
+ benedicite: 210,
13
+ };
14
+ function lookupVerses(psalm, verse) {
15
+ if (typeof psalm === "string") {
16
+ const named = CANTICLE_NAMES[psalm.toLowerCase()];
17
+ if (named)
18
+ return lookupVerses(named, verse);
19
+ const n = parseInt(psalm);
20
+ if (!isNaN(n))
21
+ return lookupVerses(n, verse);
22
+ return [];
23
+ }
24
+ let results = PSALMS.filter((v) => v.psalm === psalm);
25
+ if (verse)
26
+ results = results.filter((v) => v.verse === verse);
27
+ return results;
28
+ }
29
+ function verseToChant(v, mode, differentia, intonation) {
30
+ const gabc = intone(v, { mode, differentia, intonation });
31
+ return {
32
+ id: `psalm:${v.psalm}:${v.verse}`,
33
+ incipit: v.half1.slice(0, 40),
34
+ gabc,
35
+ office: "ps",
36
+ genus: "Psalmus",
37
+ mode: String(mode),
38
+ modus: mode === 0 ? "Tonus Peregrinus" : (MODE_LABELS[String(mode)] ?? `Modus ${mode}`),
39
+ pages: [],
40
+ source: {
41
+ book: "Psalterium",
42
+ year: new Date().getUTCFullYear(),
43
+ editor: "tonus",
44
+ },
45
+ };
46
+ }
47
+ /**
48
+ * Psalm and canticle retrieval (`tonus.psalmus`) from the Psalterium,
49
+ * intoned to the psalm tones (modes 1-8 plus tonus peregrinus) as GABC.
50
+ */
51
+ export function getPsalm(query) {
52
+ if (!query || Object.keys(query).length === 0)
53
+ return [];
54
+ const verses = lookupVerses(query.psalm ?? 0, query.verse);
55
+ if (!verses.length)
56
+ return [];
57
+ const mode = query.mode ?? 8;
58
+ return verses.map((v) => verseToChant(v, mode, query.differentia, query.intonatio));
59
+ }
60
+ //# sourceMappingURL=psalm.js.map
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Split a single Latin word into syllables.
3
+ * e.g. "Dóminus" → ["Dó", "mi", "nus"]
4
+ * "glória" → ["gló", "ri", "a"]
5
+ * "ánima" → ["á", "ni", "ma"]
6
+ */
7
+ export declare function syllabifyWord(word: string): string[];
8
+ /**
9
+ * Syllabify a phrase (sequence of words).
10
+ * Returns syllables with " " tokens preserved between words.
11
+ * e.g. "Dixit Dóminus" → ["Di","xit"," ","Dó","mi","nus"]
12
+ */
13
+ export declare function syllabifyPhrase(phrase: string): string[];
14
+ export declare function hyphenateWord(word: string): string;
15
+ export declare function selectVowel(text: string): {
16
+ vowel: string;
17
+ accent: boolean;
18
+ };
19
+ export declare function detectVowelAccent(text: string): boolean;
20
+ //# sourceMappingURL=syllabify.d.ts.map
@@ -0,0 +1,192 @@
1
+ // ---------------------------------------------------------------------------
2
+ // engines/chant/syllabify — Latin syllabification (ecclesiastical rules)
3
+ // ---------------------------------------------------------------------------
4
+ //
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
13
+ //
14
+ // Diacritics (áéíóúàèìòùâêîôû etc.) are treated as their base vowel throughout.
15
+ // ── Character classes ──
16
+ const VOWELS = new Set("aeiouyáéíóúàèìòùâêîôûäëïöüæœý");
17
+ 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"]);
21
+ // Muta cum liquida pairs that stay with the following vowel
22
+ const MUTA_CUM_LIQUIDA = new Set([
23
+ "tr",
24
+ "dr",
25
+ "pr",
26
+ "br",
27
+ "cr",
28
+ "gr",
29
+ "fr",
30
+ "pl",
31
+ "bl",
32
+ "cl",
33
+ "gl",
34
+ "fl",
35
+ "th",
36
+ "ph",
37
+ "ch",
38
+ ]);
39
+ // Strip diacritics for rule matching, keep original for output
40
+ function baseChar(ch) {
41
+ return ch
42
+ .normalize("NFD")
43
+ .replace(/[\u0300-\u036f]/g, "")
44
+ .toLowerCase();
45
+ }
46
+ function isVowelBase(ch) {
47
+ return VOWELS.has(baseChar(ch));
48
+ }
49
+ function isConsonantBase(ch) {
50
+ const b = baseChar(ch);
51
+ return /[a-z]/.test(b) && !VOWELS.has(b);
52
+ }
53
+ // ── Core syllabifier ──
54
+ /**
55
+ * Split a single Latin word into syllables.
56
+ * e.g. "Dóminus" → ["Dó", "mi", "nus"]
57
+ * "glória" → ["gló", "ri", "a"]
58
+ * "ánima" → ["á", "ni", "ma"]
59
+ */
60
+ export function syllabifyWord(word) {
61
+ if (word.length <= 2)
62
+ return [word];
63
+ const chars = Array.from(word);
64
+ const n = chars.length;
65
+ // Find all vowel positions (base-char aware).
66
+ const vpos = [];
67
+ for (let i = 0; i < n; i++) {
68
+ if (isVowelBase(chars[i])) {
69
+ const prev = i > 0 ? baseChar(chars[i - 1]) : "";
70
+ if (baseChar(chars[i]) === "u" && prev === "q")
71
+ continue;
72
+ vpos.push(i);
73
+ }
74
+ }
75
+ if (vpos.length <= 1)
76
+ return [word]; // monosyllable
77
+ // Build a set of positions where we insert a split (before this index)
78
+ const splits = new Set();
79
+ for (let vi = 0; vi < vpos.length - 1; vi++) {
80
+ const v1 = vpos[vi];
81
+ const v2 = vpos[vi + 1];
82
+ // Adjacent vowels (no consonants between them)
83
+ if (v2 === v1 + 1) {
84
+ const pair = baseChar(chars[v1]) + baseChar(chars[v2]);
85
+ if (DIPHTHONGS.has(pair))
86
+ continue;
87
+ // All other adjacent vowels split in ecclesiastical Latin
88
+ splits.add(v2);
89
+ continue;
90
+ }
91
+ // Consonants between v1 and v2
92
+ const consonants = chars.slice(v1 + 1, v2);
93
+ const cLen = consonants.length;
94
+ if (cLen === 0) {
95
+ // Shouldn't happen after digraph check, but guard
96
+ splits.add(v2);
97
+ }
98
+ else if (cLen === 1) {
99
+ // Single consonant → goes with following vowel: V | CV
100
+ splits.add(v1 + 1);
101
+ }
102
+ else {
103
+ // Cluster — check last two consonants for muta cum liquida
104
+ const last2 = consonants
105
+ .slice(-2)
106
+ .map((c) => baseChar(c))
107
+ .join("");
108
+ if (MUTA_CUM_LIQUIDA.has(last2)) {
109
+ // Keep muta+liquida together with following vowel: VC…·tCV
110
+ splits.add(v2 - 2);
111
+ }
112
+ else {
113
+ // Split after first consonant: VC·C…V
114
+ splits.add(v1 + 2);
115
+ }
116
+ }
117
+ }
118
+ // Build syllable strings from split positions
119
+ const splitArr = Array.from(splits).sort((a, b) => a - b);
120
+ const result = [];
121
+ let prev = 0;
122
+ for (const pos of splitArr) {
123
+ if (pos > prev)
124
+ result.push(chars.slice(prev, pos).join(""));
125
+ prev = pos;
126
+ }
127
+ result.push(chars.slice(prev).join(""));
128
+ return result.filter((s) => s.length > 0);
129
+ }
130
+ /**
131
+ * Syllabify a phrase (sequence of words).
132
+ * Returns syllables with " " tokens preserved between words.
133
+ * e.g. "Dixit Dóminus" → ["Di","xit"," ","Dó","mi","nus"]
134
+ */
135
+ export function syllabifyPhrase(phrase) {
136
+ const tokens = phrase.split(/(\s+)/);
137
+ const result = [];
138
+ for (const token of tokens) {
139
+ if (!token.trim()) {
140
+ if (token)
141
+ result.push(" ");
142
+ continue;
143
+ }
144
+ // Strip leading/trailing punctuation, reattach after syllabification
145
+ const m = token.match(/^([^a-zA-ZÀ-ÿ]*)(.*?)([^a-zA-ZÀ-ÿ]*)$/u);
146
+ if (!m) {
147
+ result.push(token);
148
+ continue;
149
+ }
150
+ const [, lead, word, trail] = m;
151
+ if (!word) {
152
+ result.push(token);
153
+ continue;
154
+ }
155
+ const sylls = syllabifyWord(word);
156
+ if (lead)
157
+ sylls[0] = lead + sylls[0];
158
+ if (trail)
159
+ sylls[sylls.length - 1] += trail;
160
+ result.push(...sylls);
161
+ }
162
+ return result.filter((s, i) => !(s === " " && (i === 0 || i === result.length - 1)));
163
+ }
164
+ export function hyphenateWord(word) {
165
+ return syllabifyWord(word).join(SOFT_HYPHEN);
166
+ }
167
+ const ACCENTED = /[\u0301]/; // combining acute accent (NFD form)
168
+ export function selectVowel(text) {
169
+ const expanded = text.replace(/ǽ/g, "áe").replace(/æ/g, "ae").replace(/œ/g, "oe");
170
+ const nfd = expanded.normalize("NFD");
171
+ let firstVowel = "";
172
+ let accentedVowel = "";
173
+ for (let i = 0; i < nfd.length; i++) {
174
+ const ch = nfd[i];
175
+ const base = ch.replace(/[\u0300-\u036f]/g, "").toLowerCase();
176
+ if (!isVowelBase(base))
177
+ continue;
178
+ const v = base === "y" ? "i" : base;
179
+ if (!firstVowel)
180
+ firstVowel = v;
181
+ if (!accentedVowel && i + 1 < nfd.length && ACCENTED.test(nfd[i + 1])) {
182
+ accentedVowel = v;
183
+ }
184
+ }
185
+ if (accentedVowel)
186
+ return { vowel: accentedVowel, accent: true };
187
+ return { vowel: firstVowel, accent: false };
188
+ }
189
+ export function detectVowelAccent(text) {
190
+ return selectVowel(text).accent;
191
+ }
192
+ //# sourceMappingURL=syllabify.js.map