tonus 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/BIBLIOGRAPHY.md CHANGED
@@ -17,6 +17,8 @@ section of the relevant page.
17
17
  [docs/chant.md](docs/chant.md#sources)
18
18
  - **Versus Psalmorum et Canticorum (Solesmes, No. 839)** — psalm-verse
19
19
  formulas → [docs/chant.md](docs/chant.md#sources)
20
+ - **Bloomfield, _Compline_ (github.com/bbloomf/compline, public domain)**
21
+ — reference dates & chants for the traditional Roman Compline ordo → [docs/chant.md](docs/chant.md#sources)
20
22
 
21
23
  ## Chant rhythm (score engine)
22
24
 
@@ -0,0 +1,38 @@
1
+ import type { Season } from "../engines/cal/types.js";
2
+ /** The invariable spine — chants sung the same every night. */
3
+ export declare const COMPLINE_ORDINARY: {
4
+ /** Deus in adjutorium — the opening versicle. */
5
+ readonly opening: "gregobase:501";
6
+ /** Nunc dimittis — the gospel canticle. */
7
+ readonly canticle: "gregobase:1346";
8
+ };
9
+ /**
10
+ * Season → the seasonal Te lucis ante terminum (hymn) and In manus tuas
11
+ * (short responsory) chant ids. Feast-specific tones (Ascension, Sacred Heart,
12
+ * BVM octaves, …) are refinements deferred to a later pass; this maps the seven
13
+ * calendar seasons to their plain seasonal setting.
14
+ */
15
+ export declare const COMPLINE_SEASONAL: Readonly<Record<Season, {
16
+ teLucis: string;
17
+ inManusTuas: string;
18
+ }>>;
19
+ /** The four seasonal Marian antiphons (simple tone). */
20
+ export declare const MARIAN_ANTIPHONS: {
21
+ readonly alma: "gregobase:1851";
22
+ readonly ave: "gregobase:2153";
23
+ readonly reginaCaeli: "gregobase:2290";
24
+ readonly salve: "gregobase:2435";
25
+ };
26
+ /**
27
+ * The seasonal Marian antiphon, chosen by the traditional four-way rotation.
28
+ * The boundaries do not align with the calendar seasons — the Alma → Ave switch
29
+ * falls on Candlemas (2 February), mid-season — so this uses the feast's season
30
+ * for the broad divisions and its date for the Candlemas cut.
31
+ *
32
+ * Advent 1 → 1 Feb Alma Redemptoris Mater
33
+ * 2 Feb → Holy Wednesday Ave Regina caelorum
34
+ * Eastertide Regina caeli
35
+ * Pentecost → Advent Salve Regina
36
+ */
37
+ export declare function marianAntiphonFor(season: Season, date: Date): string;
38
+ //# sourceMappingURL=compline.d.ts.map
@@ -0,0 +1,73 @@
1
+ // data/compline — the Ordo of Compline (completorium)
2
+ //
3
+ // Compline in the traditional Roman rite is nearly invariable: the same spine
4
+ // every night, varying only by liturgical season (the hymn Te lucis and the
5
+ // short responsory In manus tuas) and by the seasonal Marian antiphon at the
6
+ // end. This is an editorial ordo — it references chants already in the corpus
7
+ // by their gregobase: id, it does not carry any GABC of its own (cf. masses.ts,
8
+ // which is likewise a hand-authored profile table, not corpus-extracted).
9
+ //
10
+ // Ordo structure after the traditional Roman Compline; seasonal assignment and
11
+ // the Marian rotation follow standard practice, cross-checked against
12
+ // bbloomf/compline (public domain). See docs/chant.md and BIBLIOGRAPHY.md.
13
+ // Compline's psalmody (Ps 4, 30 vv. 2–6, 90, 133) is not hand-listed here — it
14
+ // comes from the extracted DO Tridentine scheme via
15
+ // `officePsalmPortions("Completorium", …)`. Only the ordo structure and the
16
+ // seasonal/Marian rules are editorial and live in this file.
17
+ /** The invariable spine — chants sung the same every night. */
18
+ export const COMPLINE_ORDINARY = {
19
+ /** Deus in adjutorium — the opening versicle. */
20
+ opening: "gregobase:501",
21
+ /** Nunc dimittis — the gospel canticle. */
22
+ canticle: "gregobase:1346",
23
+ };
24
+ /**
25
+ * Season → the seasonal Te lucis ante terminum (hymn) and In manus tuas
26
+ * (short responsory) chant ids. Feast-specific tones (Ascension, Sacred Heart,
27
+ * BVM octaves, …) are refinements deferred to a later pass; this maps the seven
28
+ * calendar seasons to their plain seasonal setting.
29
+ */
30
+ export const COMPLINE_SEASONAL = Object.freeze({
31
+ adv: { teLucis: "gregobase:12752", inManusTuas: "gregobase:12702" }, // Advent
32
+ nat: { teLucis: "gregobase:12379", inManusTuas: "gregobase:13059" }, // Christmastide
33
+ epi: { teLucis: "gregobase:12897", inManusTuas: "gregobase:13059" }, // after Epiphany
34
+ quadp: { teLucis: "gregobase:12379", inManusTuas: "gregobase:13059" }, // Septuagesima
35
+ quad: { teLucis: "gregobase:12673", inManusTuas: "gregobase:13059" }, // Lent
36
+ pasc: { teLucis: "gregobase:12092", inManusTuas: "gregobase:12649" }, // Paschaltide
37
+ pent: { teLucis: "gregobase:12379", inManusTuas: "gregobase:13059" }, // after Pentecost
38
+ });
39
+ /** The four seasonal Marian antiphons (simple tone). */
40
+ export const MARIAN_ANTIPHONS = {
41
+ alma: "gregobase:1851", // Alma Redemptoris Mater — Advent to Candlemas
42
+ ave: "gregobase:2153", // Ave Regina caelorum — Candlemas to Holy Week
43
+ reginaCaeli: "gregobase:2290", // Regina caeli — Eastertide
44
+ salve: "gregobase:2435", // Salve Regina — Trinity to Advent
45
+ };
46
+ /**
47
+ * The seasonal Marian antiphon, chosen by the traditional four-way rotation.
48
+ * The boundaries do not align with the calendar seasons — the Alma → Ave switch
49
+ * falls on Candlemas (2 February), mid-season — so this uses the feast's season
50
+ * for the broad divisions and its date for the Candlemas cut.
51
+ *
52
+ * Advent 1 → 1 Feb Alma Redemptoris Mater
53
+ * 2 Feb → Holy Wednesday Ave Regina caelorum
54
+ * Eastertide Regina caeli
55
+ * Pentecost → Advent Salve Regina
56
+ */
57
+ export function marianAntiphonFor(season, date) {
58
+ if (season === "pasc")
59
+ return MARIAN_ANTIPHONS.reginaCaeli;
60
+ if (season === "adv")
61
+ return MARIAN_ANTIPHONS.alma;
62
+ if (season === "pent")
63
+ return MARIAN_ANTIPHONS.salve;
64
+ // nat / epi / quadp / quad straddle Candlemas (2 February). Alma runs from
65
+ // Advent through 1 February — so Christmastide and any date before Candlemas
66
+ // (December or January) is still Alma; 2 February onward is Ave, on through
67
+ // Holy Week (the tail of `quad`, before Paschaltide takes over).
68
+ const month = date.getUTCMonth(); // 0 = Jan, 11 = Dec
69
+ const beforeCandlemas = month === 11 || month === 0 ||
70
+ (month === 1 && date.getUTCDate() < 2);
71
+ return beforeCandlemas ? MARIAN_ANTIPHONS.alma : MARIAN_ANTIPHONS.ave;
72
+ }
73
+ //# sourceMappingURL=compline.js.map
@@ -0,0 +1,15 @@
1
+ export interface OfficePsalmPortion {
2
+ psalm: number;
3
+ from?: number;
4
+ to?: number;
5
+ }
6
+ export interface OfficePsalmEntry {
7
+ hour: "Prima" | "Tertia" | "Sexta" | "Nona" | "Completorium";
8
+ /** 0–6 (0 = Sunday); null for the feast set or hours with no weekday split. */
9
+ weekday: number | null;
10
+ /** True for the feast (Festis) psalmody. */
11
+ festis: boolean;
12
+ psalms: OfficePsalmPortion[];
13
+ }
14
+ export declare const OFFICE_PSALMS: OfficePsalmEntry[];
15
+ //# sourceMappingURL=office-psalms.d.ts.map
@@ -0,0 +1,28 @@
1
+ // office-psalms.ts — little-hours psalmody (Prime, Terce, Sext, None, Compline)
2
+ // Extracted from Divinum Officium (Psalterium, [Tridentinum] section) by
3
+ // scripts/extract-office-psalms.mjs
4
+ // Generated: 2026-07-05T01:25:28.943Z
5
+ // Entries: 15
6
+ //
7
+ // The traditional (pre-1911) Roman little-hours psalm distribution. Each entry
8
+ // gives the psalm portions for one hour on one weekday (0 = Sunday), or the
9
+ // feast set (festis: true, weekday: null). A portion is a whole psalm or an
10
+ // inclusive verse range.
11
+ export const OFFICE_PSALMS = [
12
+ { hour: "Prima", weekday: 0, festis: false, psalms: [{ psalm: 53 }, { psalm: 117 }, { psalm: 118, from: 1, to: 16 }, { psalm: 118, from: 17, to: 32 }] },
13
+ { hour: "Prima", weekday: 1, festis: false, psalms: [{ psalm: 53 }, { psalm: 23 }, { psalm: 118, from: 1, to: 16 }, { psalm: 118, from: 17, to: 32 }] },
14
+ { hour: "Prima", weekday: 2, festis: false, psalms: [{ psalm: 53 }, { psalm: 24 }, { psalm: 118, from: 1, to: 16 }, { psalm: 118, from: 17, to: 32 }] },
15
+ { hour: "Prima", weekday: 3, festis: false, psalms: [{ psalm: 53 }, { psalm: 25 }, { psalm: 118, from: 1, to: 16 }, { psalm: 118, from: 17, to: 32 }] },
16
+ { hour: "Prima", weekday: 4, festis: false, psalms: [{ psalm: 53 }, { psalm: 22 }, { psalm: 118, from: 1, to: 16 }, { psalm: 118, from: 17, to: 32 }] },
17
+ { hour: "Prima", weekday: 5, festis: false, psalms: [{ psalm: 53 }, { psalm: 21 }, { psalm: 118, from: 1, to: 16 }, { psalm: 118, from: 17, to: 32 }] },
18
+ { hour: "Prima", weekday: 6, festis: false, psalms: [{ psalm: 53 }, { psalm: 118, from: 1, to: 16 }, { psalm: 118, from: 17, to: 32 }] },
19
+ { hour: "Prima", weekday: null, festis: true, psalms: [{ psalm: 53 }, { psalm: 118, from: 1, to: 16 }, { psalm: 118, from: 17, to: 32 }] },
20
+ { hour: "Tertia", weekday: 0, festis: false, psalms: [{ psalm: 118, from: 33, to: 48 }, { psalm: 118, from: 49, to: 64 }, { psalm: 118, from: 65, to: 80 }] },
21
+ { hour: "Tertia", weekday: null, festis: false, psalms: [{ psalm: 118, from: 33, to: 48 }, { psalm: 118, from: 49, to: 64 }, { psalm: 118, from: 65, to: 80 }] },
22
+ { hour: "Sexta", weekday: 0, festis: false, psalms: [{ psalm: 118, from: 81, to: 96 }, { psalm: 118, from: 97, to: 112 }, { psalm: 118, from: 113, to: 128 }] },
23
+ { hour: "Sexta", weekday: null, festis: false, psalms: [{ psalm: 118, from: 81, to: 96 }, { psalm: 118, from: 97, to: 112 }, { psalm: 118, from: 113, to: 128 }] },
24
+ { hour: "Nona", weekday: 0, festis: false, psalms: [{ psalm: 118, from: 129, to: 144 }, { psalm: 118, from: 145, to: 160 }, { psalm: 118, from: 161, to: 176 }] },
25
+ { hour: "Nona", weekday: null, festis: false, psalms: [{ psalm: 118, from: 129, to: 144 }, { psalm: 118, from: 145, to: 160 }, { psalm: 118, from: 161, to: 176 }] },
26
+ { hour: "Completorium", weekday: null, festis: false, psalms: [{ psalm: 4 }, { psalm: 30, from: 2, to: 6 }, { psalm: 90 }, { psalm: 133 }] }
27
+ ];
28
+ //# sourceMappingURL=office-psalms.js.map
@@ -0,0 +1,20 @@
1
+ import type { Season } from "../engines/cal/types.js";
2
+ /** The invariable spine. */
3
+ export declare const PRIME_ORDINARY: {
4
+ /** Deus in adjutorium — the opening versicle. */
5
+ readonly opening: "gregobase:501";
6
+ /**
7
+ * Iam lucis orto sidere — Prime's hymn. Its variants are keyed by feast rank
8
+ * (ferial / Sunday / major feast) rather than by season; the ferial-and-
9
+ * simple-feast tone is the everyday default.
10
+ */
11
+ readonly hymn: "gregobase:11944";
12
+ };
13
+ /**
14
+ * Season → the seasonal short responsory Christe Fili Dei vivi. Only Advent and
15
+ * Paschaltide have their own setting; the rest of the year uses "per Annum."
16
+ */
17
+ export declare const PRIME_SEASONAL: Readonly<Record<Season, {
18
+ responsory: string;
19
+ }>>;
20
+ //# sourceMappingURL=prime.d.ts.map
@@ -0,0 +1,42 @@
1
+ // data/prime — the Ordo of Prime (prima)
2
+ //
3
+ // Prime, the "first hour," is like Compline mostly fixed and seasonal rather
4
+ // than per-feast. This ordo covers Prime's SUNG parts: the opening, the hymn
5
+ // Iam lucis orto sidere, the fixed psalmody, and the seasonal short responsory
6
+ // Christe Fili Dei vivi. Prime's recited/monotoned parts — the Athanasian Creed
7
+ // (Quicumque vult), the martyrology, the chapter, and the collect — are not
8
+ // Solesmes chant and are not in the corpus, so they are out of scope; this is a
9
+ // chant ordo, not a full Breviary Prime.
10
+ //
11
+ // Like data/compline.ts and data/masses.ts this is a hand-authored table that
12
+ // references chants already in the corpus by id; it carries no GABC of its own.
13
+ // See docs/chant.md and BIBLIOGRAPHY.md.
14
+ // Prime's psalmody (Ps 53 + a weekday-proper psalm + Ps 118 in two sections,
15
+ // varying by weekday per DO's Tridentine scheme) is not hand-listed here — it
16
+ // comes from the extracted scheme via `officePsalmPortions("Prima", weekday)`.
17
+ // Only the ordo structure and the seasonal responsory rule are editorial.
18
+ /** The invariable spine. */
19
+ export const PRIME_ORDINARY = {
20
+ /** Deus in adjutorium — the opening versicle. */
21
+ opening: "gregobase:501",
22
+ /**
23
+ * Iam lucis orto sidere — Prime's hymn. Its variants are keyed by feast rank
24
+ * (ferial / Sunday / major feast) rather than by season; the ferial-and-
25
+ * simple-feast tone is the everyday default.
26
+ */
27
+ hymn: "gregobase:11944",
28
+ };
29
+ /**
30
+ * Season → the seasonal short responsory Christe Fili Dei vivi. Only Advent and
31
+ * Paschaltide have their own setting; the rest of the year uses "per Annum."
32
+ */
33
+ export const PRIME_SEASONAL = Object.freeze({
34
+ adv: { responsory: "gregobase:13127" }, // Tempore Adventus
35
+ nat: { responsory: "gregobase:11818" }, // per Annum
36
+ epi: { responsory: "gregobase:11818" },
37
+ quadp: { responsory: "gregobase:11818" },
38
+ quad: { responsory: "gregobase:11818" },
39
+ pasc: { responsory: "gregobase:12439" }, // Tempore Paschali
40
+ pent: { responsory: "gregobase:11818" },
41
+ });
42
+ //# sourceMappingURL=prime.js.map
@@ -2,15 +2,82 @@
2
2
  // engines/chant/hour — Divine Office hour retrieval
3
3
  // ---------------------------------------------------------------------------
4
4
  import { resolveChant, resolveChants } from "./chant.js";
5
+ import { intonePortion, officePsalmPortions } from "./psalm.js";
5
6
  import { temporaSundayId } from "../cal/date.js";
7
+ import { getFeast } from "../cal/calendar.js";
6
8
  import { OFFICE_ROMAN } from "../../data/office-roman.js";
9
+ import { COMPLINE_ORDINARY, COMPLINE_SEASONAL, marianAntiphonFor, } from "../../data/compline.js";
10
+ import { PRIME_ORDINARY, PRIME_SEASONAL } from "../../data/prime.js";
7
11
  let _roman = null;
8
12
  function romanMap() {
9
13
  if (!_roman)
10
14
  _roman = new Map(OFFICE_ROMAN.map((d) => [d.feastId, d]));
11
15
  return _roman;
12
16
  }
17
+ // Hours whose result is an ordered sequence (an ordo) rather than a set of
18
+ // chants — they keep assembly order instead of being sorted by incipit.
19
+ const ORDERED_ORDO_HOURS = new Set([
20
+ "prima", "tertia", "sexta", "nona", "completorium",
21
+ ]);
22
+ // The purely seasonal/fixed hours — identical for every feast of a day, so
23
+ // concurrent feasts collapse to one and a no-feast query resolves the default
24
+ // epoch. (Terce/Sext/None are NOT here: their responsory breve is per-feast.)
25
+ const SEASONAL_ORDO_HOURS = new Set([
26
+ "prima", "completorium",
27
+ ]);
28
+ // Compline is fixed and seasonal, not per-feast: it does not use the OfficeDay
29
+ // tables at all. The ordo is assembled from the season (Te lucis, In manus
30
+ // tuas), the fixed psalms (from the extracted DO scheme), the invariable spine
31
+ // (Deus in adjutorium, Nunc dimittis), and the date-driven Marian antiphon.
32
+ // See data/compline.ts.
33
+ function complineForFeast(feast) {
34
+ const seasonal = COMPLINE_SEASONAL[feast.season];
35
+ const results = [];
36
+ const opening = resolveChant(COMPLINE_ORDINARY.opening);
37
+ if (opening)
38
+ results.push(opening);
39
+ for (const p of officePsalmPortions("Completorium", feast.weekday)) {
40
+ results.push(...intonePortion(p));
41
+ }
42
+ const hymn = seasonal && resolveChant(seasonal.teLucis);
43
+ if (hymn)
44
+ results.push(hymn);
45
+ const responsory = seasonal && resolveChant(seasonal.inManusTuas);
46
+ if (responsory)
47
+ results.push(responsory);
48
+ const canticle = resolveChant(COMPLINE_ORDINARY.canticle);
49
+ if (canticle)
50
+ results.push(canticle);
51
+ const marian = resolveChant(marianAntiphonFor(feast.season, feast.date));
52
+ if (marian)
53
+ results.push(marian);
54
+ return results;
55
+ }
56
+ // Prime, like Compline, is a fixed+seasonal ordo, not per-feast. Covers the
57
+ // sung parts only (see data/prime.ts): opening, fixed psalms, the hymn Iam
58
+ // lucis, and the seasonal short responsory Christe Fili Dei.
59
+ function primeForFeast(feast) {
60
+ const seasonal = PRIME_SEASONAL[feast.season];
61
+ const results = [];
62
+ const opening = resolveChant(PRIME_ORDINARY.opening);
63
+ if (opening)
64
+ results.push(opening);
65
+ const hymn = resolveChant(PRIME_ORDINARY.hymn);
66
+ if (hymn)
67
+ results.push(hymn);
68
+ for (const p of officePsalmPortions("Prima", feast.weekday)) {
69
+ results.push(...intonePortion(p));
70
+ }
71
+ const responsory = seasonal && resolveChant(seasonal.responsory);
72
+ if (responsory)
73
+ results.push(responsory);
74
+ return results;
75
+ }
13
76
  function chantsForFeastHour(feast, hour) {
77
+ if (hour === "completorium")
78
+ return complineForFeast(feast);
79
+ if (hour === "prima")
80
+ return primeForFeast(feast);
14
81
  const map = romanMap();
15
82
  const sunday = temporaSundayId(feast.id);
16
83
  const day = map.get(feast.id) ?? (sunday ? (map.get(sunday) ?? null) : null);
@@ -36,18 +103,21 @@ function chantsForFeastHour(feast, hour) {
36
103
  if (hy)
37
104
  results.push(hy);
38
105
  }
39
- else if (hour === "tertia") {
40
- const rb = resolveChant(day.respBreveTertia);
41
- if (rb)
42
- results.push(rb);
43
- }
44
- else if (hour === "sexta") {
45
- const rb = resolveChant(day.respBreveSexta);
46
- if (rb)
47
- results.push(rb);
48
- }
49
- else if (hour === "nona") {
50
- const rb = resolveChant(day.respBreveNona);
106
+ else if (hour === "tertia" || hour === "sexta" || hour === "nona") {
107
+ // The little hours: their portion of Ps 118 (Terce vv. 33–80, Sext 81–128,
108
+ // None 129–176, from the extracted DO scheme), then the responsory breve.
109
+ // The psalmody belongs to a specific day, so it is only included for a real
110
+ // feast query — not the all-days survey scan (which has no date and would
111
+ // repeat the psalms once per feast).
112
+ if (feast.date) {
113
+ const hourName = hour === "tertia" ? "Tertia" : hour === "sexta" ? "Sexta" : "Nona";
114
+ for (const p of officePsalmPortions(hourName, feast.weekday)) {
115
+ results.push(...intonePortion(p));
116
+ }
117
+ }
118
+ const rb = resolveChant(hour === "tertia" ? day.respBreveTertia
119
+ : hour === "sexta" ? day.respBreveSexta
120
+ : day.respBreveNona);
51
121
  if (rb)
52
122
  results.push(rb);
53
123
  }
@@ -79,14 +149,29 @@ export function getHour(query) {
79
149
  const hour = query.hora;
80
150
  let results;
81
151
  if (feasts && hour) {
82
- results = feasts.flatMap((f) => chantsForFeastHour(f, hour));
152
+ // Prime and Compline are seasonal/weekday ordos, identical for every feast
153
+ // of the day — so concurrent feasts collapse to a single ordo rather than
154
+ // repeating it. The other hours are genuinely per-feast.
155
+ results = SEASONAL_ORDO_HOURS.has(hour)
156
+ ? feasts[0] ? chantsForFeastHour(feasts[0], hour) : []
157
+ : feasts.flatMap((f) => chantsForFeastHour(f, hour));
83
158
  }
84
159
  else if (feasts) {
85
- const hours = ["matutinum", "laudes", "tertia", "sexta", "nona", "vesperae"];
160
+ const hours = [
161
+ "matutinum", "laudes", "prima", "tertia", "sexta", "nona",
162
+ "vesperae", "completorium",
163
+ ];
86
164
  results = feasts.flatMap((f) => hours.flatMap((h) => chantsForFeastHour(f, h)));
87
165
  }
166
+ else if (hour && SEASONAL_ORDO_HOURS.has(hour)) {
167
+ // Prime and Compline are seasonal ordos, not per-feast. With no feast,
168
+ // resolve for the default epoch (Guido d'Arezzo's era) — festum()'s anchor.
169
+ const [feast] = getFeast();
170
+ results = feast ? chantsForFeastHour(feast, hour) : [];
171
+ }
88
172
  else if (hour) {
89
- // Hour without feast — scan all office entries
173
+ // Hour without feast — survey per-feast content across all office entries.
174
+ // mockFeast has no date, so the little hours return only their responsories.
90
175
  results = OFFICE_ROMAN.flatMap((day) => {
91
176
  const mockFeast = { id: day.feastId };
92
177
  return chantsForFeastHour(mockFeast, hour);
@@ -119,15 +204,21 @@ export function getHour(query) {
119
204
  const ids = new Set(toArray(query.id));
120
205
  results = results.filter((c) => ids.has(c.id));
121
206
  }
122
- const sort = query.sort ?? "incipit";
123
- results.sort((a, b) => {
124
- if (sort === "id")
125
- return a.id.localeCompare(b.id);
126
- if (sort === "mode")
127
- return (String(a.mode ?? "").localeCompare(String(b.mode ?? "")) ||
128
- a.incipit.localeCompare(b.incipit));
129
- return a.incipit.localeCompare(b.incipit);
130
- });
207
+ // The little hours and Compline are ordered ordos — their sequence IS the
208
+ // content so they keep assembly order unless the caller explicitly asks for
209
+ // a sort. The other hours return a set of chants, sorted by incipit.
210
+ const isOrderedOrdo = query.hora != null && ORDERED_ORDO_HOURS.has(query.hora);
211
+ if (query.sort || !isOrderedOrdo) {
212
+ const sort = query.sort ?? "incipit";
213
+ results.sort((a, b) => {
214
+ if (sort === "id")
215
+ return a.id.localeCompare(b.id);
216
+ if (sort === "mode")
217
+ return (String(a.mode ?? "").localeCompare(String(b.mode ?? "")) ||
218
+ a.incipit.localeCompare(b.incipit));
219
+ return a.incipit.localeCompare(b.incipit);
220
+ });
221
+ }
131
222
  const offset = Math.max(0, query.offset ?? 0);
132
223
  const limit = query.limit == null ? results.length : Math.max(0, query.limit);
133
224
  return results.slice(offset, offset + limit);
@@ -1,7 +1,24 @@
1
+ import { type OfficePsalmEntry, type OfficePsalmPortion } from "../../data/office-psalms.js";
1
2
  import { type Chant, type PsalmusQuery } from "./types.js";
2
3
  /**
3
4
  * Psalm and canticle retrieval (`tonus.psalmus`) from the Psalterium,
4
5
  * intoned to the psalm tones (modes 1-8 plus tonus peregrinus) as GABC.
5
6
  */
6
7
  export declare function getPsalm(query?: PsalmusQuery): Chant[];
8
+ /**
9
+ * A contiguous verse range of one psalm, intoned — e.g. `getPsalmRange(30, 2, 6)`
10
+ * for Ps 30, verses 2–6. Used by the fixed office ordos (Prime, Compline), whose
11
+ * psalmody takes only a portion of a psalm (Ps 30:2-6; Ps 118 in sections).
12
+ * `lo`/`hi` are inclusive verse numbers; split verses (3a/3b) are both included.
13
+ */
14
+ export declare function getPsalmRange(psalm: number, lo: number, hi: number, mode?: number): Chant[];
15
+ /** A psalm portion — whole psalm or an inclusive verse range — intoned. */
16
+ export declare function intonePortion(p: OfficePsalmPortion, mode?: number): Chant[];
17
+ /**
18
+ * The little-hours psalmody for one hour on a given weekday (0 = Sunday), from
19
+ * the extracted DO Tridentine scheme (`office-psalms.ts`). Prefers the
20
+ * weekday-specific entry, then the ferial default (weekday null), then the
21
+ * feast set; returns the psalm portions (not yet intoned).
22
+ */
23
+ export declare function officePsalmPortions(hour: OfficePsalmEntry["hour"], weekday: number): OfficePsalmPortion[];
7
24
  //# sourceMappingURL=psalm.d.ts.map
@@ -2,6 +2,7 @@
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.js";
5
6
  import { intone } from "./intone.js";
6
7
  import { MODE_LABELS } from "./types.js";
7
8
  const CANTICLE_NAMES = {
@@ -57,4 +58,39 @@ export function getPsalm(query) {
57
58
  const mode = query.mode ?? 8;
58
59
  return verses.map((v) => verseToChant(v, mode, query.differentia, query.intonatio));
59
60
  }
61
+ /**
62
+ * A contiguous verse range of one psalm, intoned — e.g. `getPsalmRange(30, 2, 6)`
63
+ * for Ps 30, verses 2–6. Used by the fixed office ordos (Prime, Compline), whose
64
+ * psalmody takes only a portion of a psalm (Ps 30:2-6; Ps 118 in sections).
65
+ * `lo`/`hi` are inclusive verse numbers; split verses (3a/3b) are both included.
66
+ */
67
+ export function getPsalmRange(psalm, lo, hi, mode = 8) {
68
+ return PSALMS
69
+ .filter((v) => {
70
+ if (v.psalm !== psalm)
71
+ return false;
72
+ const n = parseInt(v.verse, 10);
73
+ return !isNaN(n) && n >= lo && n <= hi;
74
+ })
75
+ .map((v) => verseToChant(v, mode));
76
+ }
77
+ /** A psalm portion — whole psalm or an inclusive verse range — intoned. */
78
+ export function intonePortion(p, mode = 8) {
79
+ return p.from != null && p.to != null
80
+ ? getPsalmRange(p.psalm, p.from, p.to, mode)
81
+ : getPsalm({ psalm: p.psalm, mode });
82
+ }
83
+ /**
84
+ * The little-hours psalmody for one hour on a given weekday (0 = Sunday), from
85
+ * the extracted DO Tridentine scheme (`office-psalms.ts`). Prefers the
86
+ * weekday-specific entry, then the ferial default (weekday null), then the
87
+ * feast set; returns the psalm portions (not yet intoned).
88
+ */
89
+ export function officePsalmPortions(hour, weekday) {
90
+ const forHour = OFFICE_PSALMS.filter((e) => e.hour === hour);
91
+ const exact = forHour.find((e) => e.weekday === weekday && !e.festis);
92
+ const ferial = forHour.find((e) => e.weekday === null && !e.festis);
93
+ const festis = forHour.find((e) => e.festis);
94
+ return (exact ?? ferial ?? festis)?.psalms ?? [];
95
+ }
60
96
  //# sourceMappingURL=psalm.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tonus",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Medieval music analysis and performance: GABC plainchant exports, liturgical calendar, tuning systems, ephemeris, and the harmony of the spheres",