From 675b644c68ac5ae1b0ffa71f2f434ee95e607052 Mon Sep 17 00:00:00 2001 From: Will Estes Date: Fri, 21 Aug 2026 09:27:13 -0400 Subject: [PATCH] Add antiphons and versicle to plain ferial weekday Matins nocturns Every non-Sunday, non-Duplex+ Matins nocturn rendered bare psalms with no antiphon at all. Sourced each psalm's antiphon from Divinum Officium's Monastic Matins psalter and regrouped to fit psalter-distribution.yml's own already-established (and non-historical) weekday psalm redistribution, since the source's own per-weekday antiphon pairings don't survive that reslice intact. --- TODO.md | 42 ++++ src/data/hours/matins-ferial-antiphons.yml | 249 +++++++++++++++++++++ src/hours/matins.ts | 55 ++++- tests/hours/matins.test.ts | 31 ++- 4 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 src/data/hours/matins-ferial-antiphons.yml diff --git a/TODO.md b/TODO.md index b52a56d..deced7b 100644 --- a/TODO.md +++ b/TODO.md @@ -2201,6 +2201,48 @@ saint-name). `tests/hours/compline.test.ts` and `tests/ui/shell.test.ts` updated test` (383 tests) and `tsc --noEmit` both pass; manually verified in the browser (English, Latin, and both-language modes) against an Advent date with an active octave. +### Ferial weekday Matins nocturn antiphons + versicle — done (2026-08-21) + +Real gap found (user flagged it directly): the plain ferial weekday Matins nocturn +(`hours/matins.ts`'s single-nocturn branch, no Sunday/Duplex+ override) rendered bare psalms +with no antiphon at all — not a documented design choice like `ferialPsalmodyThreeNocturns`'s +antiphon-less fallback, just an oversight from before antiphons existed anywhere in this hour. + +Source: Divinum Officium's Monastic-labeled Matins psalter, `Latin/Psalterium/Psalmi/Psalmi +matutinum.txt`'s `[Daym1_]`-`[Daym6_]` sections (the Latin file's own unsuffixed `[Daym1]` etc. +just redirects to these via `@:` references), English parallel at the *unsuffixed* +`[Daym1]`-`[Daym6]` keys in the `English/` tree (no indirection there). Real wrinkle: +`psalter-distribution.yml`'s own `matins:` table isn't the historical per-weekday psalm set — +its own header documents a deliberate "editorial redistribution" (54 non-Sunday psalms +reclassified must-stay/could-move by length, then resliced into 6 fresh groups of 9 in +continuous psalm order), which breaks several of the source's own antiphon-sharing pairs across +weekday boundaries (e.g. Ps 43/44, originally Monday's own closing pair, now open Tuesday +instead). Resolved by keeping vu's existing 9-psalm weekday lists untouched and regrouping: +every psalm keeps its own original antiphon text, consecutive same-antiphon psalms stay grouped +whenever their pair survived the reslice intact, and a psalm whose original partner moved +elsewhere now stands alone under its own antiphon. Each weekday's single versicle is taken from +whichever source day contributes the majority of that weekday's 9 psalms, placed at the same +relative position it held there (verified group-by-group against both parallel source files — +see each weekday's own comment in the new data file for the exact reasoning). + +New `data/hours/matins-ferial-antiphons.yml` (6 weekdays, 5-6 antiphon groups each, exactly one +group per weekday carrying a versicle) + `hours/matins.ts`'s new `ferialAntiphonedNocturn`, +replacing the old bare `ferialPsalmody` call in the plain-ferial branch (`ferialPsalmody` itself +stays, still used by `ferialPsalmodyThreeNocturns`'s intentionally antiphon-less fallback). +Versicle rendered as one combined V./R. block (`"V. ...\nR. ..."`), not just the V. line — +`sundayPsalmNocturn`'s own versicle handling drops the R. line entirely (only pushes +`group.versicle.v`), which looks like a pre-existing real bug there, not replicated here on +purpose; not yet fixed, since it's Sunday's own code, out of scope for this pass. + +`tests/hours/matins.test.ts` gained a new assertion (on the existing `FERIAL_DATE` proof, +2026-12-01, Tuesday of Advent I) confirming every nocturn psalm gets its own antiphon, +correctly grouped, and exactly one versicle appears at the right point. Also fixed 3 stale +Triduum tests that asserted "no versicle part anywhere in the whole ordo" — true only because +ferial nocturns had no versicles at all before this change; now checks specifically that the +dropped *opening* versicle (and Ps 3, which it precedes) is gone, via `ordo.parts[0]` being the +nocturn's own first psalm, rather than asserting zero versicles exist. `npm test` (420 tests) +and `tsc --noEmit` both pass. + ## Known, deliberate simplifications (not bugs — working as designed) - `getDayCollects`: each collect in a multi-collect day renders as its own diff --git a/src/data/hours/matins-ferial-antiphons.yml b/src/data/hours/matins-ferial-antiphons.yml new file mode 100644 index 0000000..35a4b69 --- /dev/null +++ b/src/data/hours/matins-ferial-antiphons.yml @@ -0,0 +1,249 @@ +# Antiphons + versicles for the plain ferial weekday Matins nocturn (no +# Sunday, no Duplex+ override) — previously rendered with bare psalms and +# no antiphon at all (a genuine oversight, not a documented design choice +# like `ferialPsalmodyThreeNocturns`'s antiphon-less fallback). +# +# `psalter-distribution.yml`'s own `matins:` table is NOT the historical +# per-weekday psalm set — its own header documents a deliberate "editorial +# redistribution": the 54 non-Sunday psalms were reclassified must-stay/ +# could-move by length, then resliced into 6 fresh groups of 9 in +# continuous psalm order, breaking several original antiphon-sharing pairs +# across weekday boundaries in the process (e.g. Ps 43/44 — originally +# Monday's own closing pair under "Eructávit" — now open Tuesday instead). +# +# So this file's own groups do NOT mirror the source's per-weekday +# grouping 1:1 — they're regrouped to fit vu's already-fixed 9-psalm list: +# every psalm here keeps its own original antiphon text (sourced from +# Divinum Officium's Monastic-labeled block, `Latin/Psalterium/Psalmi/ +# Psalmi matutinum.txt`'s `[Daym1_]`-`[Daym6_]` sections, English parallel +# at `[Daym1]`-`[Daym6]` in the `English/` tree — the Latin file only +# carries the full per-weekday text under the `_`-suffixed key, redirecting +# its own unsuffixed `[Daym1]` etc. to it via `@:` references; the English +# file has no such indirection), consecutive same-antiphon psalms are kept +# grouped (this still happens whenever a pair survived the reslice +# intact), and any psalm whose original partner moved to a different +# weekday (or to a minor-hour slot, per `psalter-distribution.yml`'s own +# "could-move" psalms) now stands alone under its own antiphon instead. +# +# Versicle placement: each weekday draws its 9 psalms from 1-2 different +# original source days (see each weekday's own comment below for exactly +# which), but only one versicle fits per plain single-nocturn weekday — +# taken from whichever source day contributes the majority of that +# weekday's psalms, placed at the same relative position it held in that +# source day's own sequence (immediately after whichever group of this +# weekday's psalms was originally its next-door neighbor, once the +# psalms that moved out from between them are accounted for). +# +# Not live-verified against a running query (Monastic ferial ordinary time +# isn't independently re-derivable that way once psalter-distribution.yml's +# own redistribution has been applied) — sourced directly from the two +# parallel psalter files instead, cross-checked psalm-by-psalm against +# every entry in psalter-distribution.yml's own `matins:` table above. + +monday: + # All 9 psalms are Daym1_'s own (Monastic Matins Feria II) — no reslicing + # across weekday boundaries here, so both antiphon grouping and the + # versicle sit exactly where Daym1_ itself has them. + groups: + - psalms: [{ number: 32 }, { number: 33 }] + antiphon: + la: "Rectos decet * collaudátio." + en: "Praise is comely * for the upright." + - psalms: [{ number: 34 }] + antiphon: + la: "Expúgna * impugnántes me." + en: "Fight against them * that fight against me." + - psalms: [{ number: 36, verses: "1-26" }, { number: 36, verses: "27-40" }] + antiphon: + la: "Revéla * Dómino viam meam." + en: "Commit my way * to the Lord." + versicle: + v: { la: "Dómine, in cælo misericórdia tua.", en: "O Lord, thy mercy is in heaven," } + r: { la: "Et véritas tua usque ad nubes.", en: "And thy truth reacheth even to the clouds." } + - psalms: [{ number: 37 }] + antiphon: + la: "Ne in ira tua * árguas me Dómine." + en: "Do not chastise me * in thy wrath." + - psalms: [{ number: 38 }, { number: 39 }] + antiphon: + la: "Ut non delínquam * in lingua mea." + en: "That I sin not * with my tongue." + - psalms: [{ number: 40 }] + antiphon: + la: "Sana Dómine * ánimam meam, quia peccávi tibi." + en: "Lord, heal my soul; * for I have sinned against thee." + +tuesday: + # 43-44 are Monday's own tail (Daym1_); 47-58 are Daym2_'s own opening + # (6 of 9 psalms — the majority, hence Daym2_'s own versicle here); 59 is + # Wednesday's own opening (Daym3_). The versicle sits right after 49, + # exactly where Daym2_ itself has it (between the "49,51" and "52,53" + # groups — 51/52/53 all moved elsewhere in the reslice, but 49 and 54 + # remain each other's nearest neighbor once they're gone). + groups: + - psalms: [{ number: 43 }, { number: 44 }] + antiphon: + la: "Eructávit * cor meum verbum bonum." + en: "Mine heart * is overflowing with a good matter." + - psalms: [{ number: 47 }, { number: 48 }] + antiphon: + la: "Magnus Dóminus * et laudábilis nimis." + en: "Great is the Lord, * and greatly to be praised." + - psalms: [{ number: 49 }] + antiphon: + la: "Deus deórum * Dóminus locútus est." + en: "The God of gods * the Lord hath spoken." + versicle: + v: { la: "Ímmola Deo sacrifícium laudis.", en: "Offer to God the sacrifice of praise." } + r: { la: "Et redde Altíssimo vota tua.", en: "And pay thy vows to the most High." } + - psalms: [{ number: 54 }, { number: 55 }] + antiphon: + la: "Inténde in me * et exáudi me." + en: "Be attentive to me * and hear me." + - psalms: [{ number: 58 }] + antiphon: + la: "Justa judicáte * fílii hóminum." + en: "Judge right things, * ye sons of men." + - psalms: [{ number: 59 }] + antiphon: + la: "Da nobis Dómine * auxílium de tribulatióne." + en: "Give us * help from trouble, O Lord." + +wednesday: + # 65-72 are Daym3_'s own (7 of 9 — the majority); 73 is Thursday's own + # opening (Daym4). The versicle sits between the "67ab" and "68ab" + # groups, exactly where Daym3_ itself has it — nothing moved out from + # between those two groups in the reslice, so the position is unchanged. + groups: + - psalms: [{ number: 65 }] + antiphon: + la: "Benedícite, * gentes, Deum nostrum." + en: "O bless our God * ye people." + - psalms: [{ number: 67, verses: "2-19" }, { number: 67, verses: "20-36" }] + antiphon: + la: "In ecclésiis, * benedícite Dóminum." + en: "In the churches * bless ye the Lord." + versicle: + v: { la: "Benedícite, gentes, Deum nostrum.", en: "O bless our God, ye people." } + r: { la: "Et audítam fácite vocem laudis ejus.", en: "And make the voice of his praise to be heard." } + - psalms: [{ number: 68, verses: "2-16" }, { number: 68, verses: "17-37" }] + antiphon: + la: "Quǽrite Dóminum * et vivet ánima vestra." + en: "Seek ye the Lord * and your soul shall live." + - psalms: [{ number: 70 }] + antiphon: + la: "Dómine * Deus in adjutórium meum inténde." + en: "Make haste * O Lord God, to deliver me." + - psalms: [{ number: 71 }, { number: 72 }] + antiphon: + la: "Quam bonus * Israël Deus." + en: "How good is God * to Israel." + - psalms: [{ number: 73 }] + antiphon: + la: "Liberásti virgam * hereditátis tuæ." + en: "Thou hast redeemed the rod * of Thine inheritance." + +thursday: + # All 9 psalms are Daym4's own — no reslicing across weekday boundaries + # here either, so grouping and versicle position are both unchanged from + # the source (the versicle sits between the "78" and "79,80" groups, + # exactly as Daym4 itself has it). + groups: + - psalms: [{ number: 76 }] + antiphon: + la: "Tu es Deus * qui facis mirabília." + en: "Thou art the God * That doest wonders." + - psalms: [{ number: 77, verses: "1-35" }, { number: 77, verses: "36-72" }] + antiphon: + la: "Inclináte * aurem vestram in verba oris mei." + en: "Incline your ears * to the words of My mouth." + - psalms: [{ number: 78 }] + antiphon: + la: "Propítius esto * peccátis nostris, Dómine." + en: "Forgive us our sins * O Lord." + versicle: + v: { la: "Deus, in sancto via tua.", en: "Thy way, O God, is in the holy place." } + r: { la: "Quis Deus magnus sicut Deus noster.", en: "Who is the great God like our God?" } + - psalms: [{ number: 79 }, { number: 80 }] + antiphon: + la: "Exsultáte Deo * adjutóri nostro." + en: "Sing aloud * unto God our strength." + - psalms: [{ number: 82 }] + antiphon: + la: "Tu solus * altíssimus super omnem terram." + en: "Thou alone * art the Most High over all the earth." + - psalms: [{ number: 83 }, { number: 84 }] + antiphon: + la: "Benedixísti * Dómine terram tuam." + en: "Thou hast blessed * thy land, O Lord." + +friday: + # 85-95 are Daym5_'s own (5 of 9 — the majority: 85, 88ab, 93, 95 — note + # 93's original antiphon covered a 92/93 pair, 92 itself having moved + # elsewhere, so it's kept here on 93 alone; likewise 95's antiphon + # originally also covered 96, which the redistribution's own documented + # hand-adjustment moved to Saturday to keep both halves of Ps 103 + # together there); 101-103 are Daym6_'s own opening (4 of 9). The + # versicle sits between the "93" and "95" groups, exactly where Daym5_ + # itself has it (originally between "92/93" and "95/96" — 96 leaving for + # Saturday doesn't touch this boundary). + groups: + - psalms: [{ number: 85 }] + antiphon: + la: "Inclína Dómine, * aurem tuam, et exáudi me." + en: "Incline thy ear, O Lord, * and hear me." + - psalms: [{ number: 88, verses: "2-19" }, { number: 88, verses: "20-53" }] + antiphon: + la: "Benedíctus * Dóminus in ætérnum." + en: "Blessed be * the Lord for evermore." + - psalms: [{ number: 93 }] + antiphon: + la: "Exaltáre, * qui júdicas terram." + en: "Lift up thyself, * thou that judgest the earth." + versicle: + v: { la: "Intret orátio mea in conspéctu tuo, Dómine.", en: "Let my prayer come before Thee, O Lord." } + r: { la: "Inclína aurem tuam ad precem meam.", en: "Incline Thine ear unto my cry." } + - psalms: [{ number: 95 }] + antiphon: + la: "Cantáte Dómino, * et benedícite nómini ejus." + en: "Sing ye * to the Lord and bless his name." + - psalms: [{ number: 101 }, { number: 102 }] + antiphon: + la: "Clamor meus * ad te véniat, Deus." + en: "O God * let my cry come unto Thee." + - psalms: [{ number: 103, verses: "1-24" }, { number: 103, verses: "25-35" }] + antiphon: + la: "Bénedic, * ánima mea Dómino." + en: "Bless the Lord, * O my soul." + +saturday: + # Ps 96 is Daym5_'s own tail (moved here by the redistribution's own + # documented hand-adjustment, swapped out of Friday to let both halves + # of Ps 103 land there instead); 104-108 are Daym6_'s own tail (8 of 9 — + # the majority, hence Daym6_'s own versicle here). The versicle sits + # between the "104ab" and "105ab" groups, exactly where Daym6_ itself + # has it — nothing moved out from between those two groups. + groups: + - psalms: [{ number: 96 }] + antiphon: + la: "Cantáte Dómino, * et benedícite nómini ejus." + en: "Sing ye * to the Lord and bless his name." + - psalms: [{ number: 104, verses: "1-22" }, { number: 104, verses: "23-45" }] + antiphon: + la: "Lætétur cor * quæréntium Dóminum." + en: "Let the heart of them rejoice, * that seek the Lord." + versicle: + v: { la: "Dómine, exáudi oratiónem meam.", en: "O Lord, hear my prayer." } + r: { la: "Et clamor meus ad te véniat.", en: "And let my cry come unto thee." } + - psalms: [{ number: 105, verses: "1-31" }, { number: 105, verses: "32-48" }] + antiphon: + la: "Vísita nos, * Dómine, in salutári tuo." + en: "Visit us * with Thy salvation, O Lord." + - psalms: [{ number: 106, verses: "1-24" }, { number: 106, verses: "25-43" }] + antiphon: + la: "De necessitátibus meis * éripe me, Dómine." + en: "From my necessities, deliver me, O Lord." + - psalms: [{ number: 107 }, { number: 108 }] + antiphon: + la: "Confitébor * Dómino nimis in ore meo." + en: "I will greatly praise * the Lord with my mouth." diff --git a/src/hours/matins.ts b/src/hours/matins.ts index ff645ae..3310eb2 100644 --- a/src/hours/matins.ts +++ b/src/hours/matins.ts @@ -84,6 +84,8 @@ import { getNocturnReadings, type NocturnReading } from '../propers/nocturn-read import { getOctaveReading } from '../propers/octave-readings'; import { getMatinsPsalmodyOverride } from './matins-psalmody-overrides'; import matinsSundayAntiphonsData from '../data/hours/matins-sunday-antiphons.yml'; +import matinsFerialAntiphonsData from '../data/hours/matins-ferial-antiphons.yml'; +import type { Weekday } from '../calendar/types'; type BilingualText = Partial>; interface SundayGroup { @@ -114,6 +116,17 @@ interface MatinsSundayAntiphons { } const sundayAntiphons = matinsSundayAntiphonsData as unknown as MatinsSundayAntiphons; +interface FerialGroup { + psalms: PsalmRef[]; + antiphon: BilingualText; + versicle?: { v: BilingualText; r: BilingualText }; +} +interface FerialNocturn { + groups: FerialGroup[]; +} +type MatinsFerialAntiphons = Record, FerialNocturn>; +const ferialAntiphons = matinsFerialAntiphonsData as unknown as MatinsFerialAntiphons; + const NOCTURN_NUMERAL = ['I', 'II', 'III'] as const; type PsalmPart = Extract; @@ -221,6 +234,46 @@ function ferialPsalmody(day: LiturgicalDay): ResolvedPart[] { return psalmRefParts(getPsalmsFor('matins', day.weekday)); } +/** The plain ferial weekday nocturn's real antiphoned psalmody (see + * data/hours/matins-ferial-antiphons.yml's own header for how each + * weekday's groups/versicle were reconciled against psalter- + * distribution.yml's own "editorial redistribution" of the historical + * per-weekday psalm set). Every psalm-group carries its own antiphon + * (incipit-or-full opening, same rank rule as Sunday's own nocturns), and + * exactly one group per weekday carries a versicle+responsory, rendered + * as one combined V./R. block (not just the V. line — see the sibling + * `sundayPsalmNocturn`'s own versicle handling, which drops the R. line + * entirely; not replicated here on purpose). */ +function ferialAntiphonedNocturn(day: LiturgicalDay): ResolvedPart[] { + const nocturn = ferialAntiphons[day.weekday as Exclude]; + const isDouble = isDoubleOrHigher(resolveOfficeWinner(day)); + const parts: ResolvedPart[] = []; + for (const group of nocturn.groups) { + const { incipit, full } = splitNamedAntiphon(verifiedText(group.antiphon)); + const opening = isDouble ? full : incipit; + group.psalms.forEach((ref, i) => { + parts.push({ + kind: 'psalm', + psalmNumber: ref.number, + verses: getPsalmVerses(ref.number, ref.verses).map((v): ResolvedVerse => ({ n: v.n, text: v.text, status: v.status })), + antiphon: i === 0 ? opening : undefined, + }); + }); + parts.push({ kind: 'antiphon', text: full }); + if (group.versicle) { + const { v, r } = group.versicle; + parts.push({ + kind: 'versicle', + text: verifiedText({ + la: `V. ${v.la}\nR. ${r.la}`, + en: `V. ${v.en}\nR. ${r.en}`, + }), + }); + } + } + return parts; +} + /** Fallback for a Duplex+ weekday feast with no matins-psalmody-overrides * entry authored for its winner yet (most of them, until the bulk-content * pass — see hours/matins-psalmody-overrides.ts's own doc comment): the @@ -401,7 +454,7 @@ export function resolveOrdo(date: string): ResolvedOrdo { } parts.push({ kind: 'te-deum', text: resolveCommon('te-deum') }); } else { - parts.push(...ferialPsalmody(day)); + parts.push(...ferialAntiphonedNocturn(day)); parts.push(...(nocturn1Readings ?? [])); parts.push({ kind: 'chapter', text: resolveCommon('matins-capitulum-ferial') }); } diff --git a/tests/hours/matins.test.ts b/tests/hours/matins.test.ts index bba092f..9d95b4c 100644 --- a/tests/hours/matins.test.ts +++ b/tests/hours/matins.test.ts @@ -43,6 +43,30 @@ describe('resolveOrdo("matins", ...) ferial (1-nocturn) branch', () => { expect(ordo.parts.some((p) => p.kind === 'canticle')).toBe(false); }); + it("gives every nocturn psalm its own antiphon and includes Tuesday's own versicle (data/hours/matins-ferial-antiphons.yml)", () => { + // The nocturn's own psalms start at index 4 (after versicle/Ps3/heading/Ps94). + const nocturnPsalms = ordo.parts.slice(4).filter((p) => p.kind === 'psalm') as { + psalmNumber: number; + antiphon?: { text: Record }; + }[]; + expect(nocturnPsalms.map((p) => p.psalmNumber)).toEqual([43, 44, 47, 48, 49, 54, 55, 58, 59]); + // Groups: [43,44] [47,48] [49] [54,55] [58] [59] — antiphon only on + // each group's first psalm. + const withAntiphon = nocturnPsalms.filter((p) => p.antiphon !== undefined).map((p) => p.psalmNumber); + expect(withAntiphon).toEqual([43, 47, 49, 54, 58, 59]); + for (const p of nocturnPsalms) { + if (p.antiphon) { + expect(p.antiphon.text.la).toBeTruthy(); + expect(p.antiphon.text.en).toBeTruthy(); + } + } + const nocturnVersicles = ordo.parts.slice(4).filter((p) => p.kind === 'versicle'); + expect(nocturnVersicles).toHaveLength(1); + const versicleText = (nocturnVersicles[0] as { text: { text: Record } }).text.text; + expect(versicleText.la).toContain('Ímmola Deo sacrifícium laudis'); + expect(versicleText.la).toContain('Et redde Altíssimo vota tua'); + }); + it("resolves the user's own bible-plan readings for the day, not the historical lectionary", () => { const lessons = ordo.parts.filter((p) => p.kind === 'lesson'); // Plain 2 bible-plan readings on this particular clean ferial (no @@ -314,7 +338,12 @@ describe('resolveOrdo("matins", ...) Sacred Triduum', () => { it.each([HOLY_THURSDAY, GOOD_FRIDAY, HOLY_SATURDAY])('drops the opening versicle/Ps 3/Invitatory/hymn on %s', (date) => { const ordo = resolveOrdo('matins', date); - expect(ordo.parts.some((p) => p.kind === 'versicle')).toBe(false); + // The opening versicle itself is gone -- Ps 3 (which it precedes) is + // gone too, and the nocturn's own psalmody starts the ordo straight + // off (see the "goes straight into Nocturn 1" test below). A versicle + // further in belongs to the ferial nocturn's own antiphoned psalmody + // (matins-ferial-antiphons.yml), not the dropped opening block. + expect(ordo.parts[0]?.kind).toBe('psalm'); expect(ordo.parts.some((p) => p.kind === 'psalm' && p.psalmNumber === 3)).toBe(false); expect(ordo.parts.some((p) => p.kind === 'psalm' && p.psalmNumber === 94)).toBe(false); expect(ordo.parts.some((p) => p.kind === 'section-heading' && p.label === 'Invitatory')).toBe(false);