tonus 0.1.4 → 0.1.8

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 (73) hide show
  1. package/BIBLIOGRAPHY.md +143 -96
  2. package/CHANGELOG.md +74 -0
  3. package/README.md +7 -9
  4. package/dist/data/am.d.ts +5 -0
  5. package/dist/data/am.js +14302 -0
  6. package/dist/data/corpus-overlap.d.ts +7 -0
  7. package/dist/data/corpus-overlap.js +53 -0
  8. package/dist/data/gr.js +3 -0
  9. package/dist/data/la.js +3 -0
  10. package/dist/data/lh.js +3 -0
  11. package/dist/data/lu.js +4 -1
  12. package/dist/data/nocturnale-romanum.d.ts +5 -0
  13. package/dist/data/nocturnale-romanum.js +12524 -0
  14. package/dist/data/office-matins-roman.d.ts +19 -0
  15. package/dist/data/office-matins-roman.js +4383 -0
  16. package/dist/data/office-monastic.d.ts +3 -0
  17. package/dist/data/office-monastic.js +6956 -0
  18. package/dist/data/office-psalms-monastic.d.ts +3 -0
  19. package/dist/data/office-psalms-monastic.js +21 -0
  20. package/dist/data/{office-psalms.d.ts → office-psalms-roman.d.ts} +1 -1
  21. package/dist/data/{office-psalms.js → office-psalms-roman.js} +3 -3
  22. package/dist/engines/cal/calendar.js +16 -1
  23. package/dist/engines/cal/date.js +5 -0
  24. package/dist/engines/chant/chant.d.ts +7 -1
  25. package/dist/engines/chant/chant.js +72 -0
  26. package/dist/{data → engines/chant/data}/compline.d.ts +1 -1
  27. package/dist/{data → engines/chant/data}/masses.d.ts +1 -1
  28. package/dist/{data → engines/chant/data}/masses.js +1 -1
  29. package/dist/{data → engines/chant/data}/prime.d.ts +1 -1
  30. package/dist/{data → engines/chant/data}/prime.js +1 -1
  31. package/dist/engines/chant/hour.js +37 -22
  32. package/dist/engines/chant/intone.js +6 -1
  33. package/dist/engines/chant/matutinum.d.ts +33 -0
  34. package/dist/engines/chant/matutinum.js +81 -0
  35. package/dist/engines/chant/ordinary.js +1 -1
  36. package/dist/engines/chant/psalm.d.ts +4 -4
  37. package/dist/engines/chant/psalm.js +6 -4
  38. package/dist/engines/chant/types.d.ts +37 -1
  39. package/dist/engines/harmonia/api.js +11 -0
  40. package/dist/engines/harmonia/data/doctrines.js +27 -1
  41. package/dist/engines/imprint.js +7 -1
  42. package/dist/engines/planet/position.js +51 -5
  43. package/dist/engines/score/articulation.js +60 -3
  44. package/dist/engines/score/cadence.js +3 -3
  45. package/dist/engines/score/emitters/midi.js +6 -2
  46. package/dist/engines/score/infer.js +13 -1
  47. package/dist/engines/score/ir.d.ts +2 -1
  48. package/dist/engines/score/ir.js +54 -12
  49. package/dist/engines/score/modulation.js +2 -2
  50. package/dist/engines/score/parse.js +16 -8
  51. package/dist/engines/score/phrasing.js +26 -0
  52. package/dist/engines/score/prosody.js +5 -0
  53. package/dist/engines/score/tabula.d.ts +2 -0
  54. package/dist/engines/score/tabula.js +1 -0
  55. package/dist/engines/score/types.d.ts +11 -2
  56. package/dist/engines/temper/api.js +1 -1
  57. package/dist/engines/temper/data/guido.d.ts +3 -2
  58. package/dist/engines/temper/data/guido.js +30 -25
  59. package/dist/engines/temper/data/modes.js +3 -0
  60. package/dist/{data → engines/temper/data}/tones.js +1 -1
  61. package/dist/engines/temper/gabc.js +25 -2
  62. package/dist/engines/temper/guido.d.ts +3 -2
  63. package/dist/engines/temper/interval.js +12 -0
  64. package/dist/engines/temper/modality.js +5 -4
  65. package/dist/engines/temper/scale.js +33 -2
  66. package/dist/engines/temper/step.d.ts +2 -2
  67. package/dist/index.d.ts +8 -4
  68. package/dist/index.js +4 -1
  69. package/package.json +2 -1
  70. package/dist/data/office.d.ts +0 -12
  71. package/dist/data/office.js +0 -13052
  72. /package/dist/{data → engines/chant/data}/compline.js +0 -0
  73. /package/dist/{data → engines/temper/data}/tones.d.ts +0 -0
@@ -0,0 +1,3 @@
1
+ import type { OfficePsalmEntry } from "./office-psalms-roman.js";
2
+ export declare const OFFICE_PSALMS_MONASTIC: OfficePsalmEntry[];
3
+ //# sourceMappingURL=office-psalms-monastic.d.ts.map
@@ -0,0 +1,21 @@
1
+ export const OFFICE_PSALMS_MONASTIC = [
2
+ { hour: "Prima", weekday: 0, festis: false, psalms: [{ psalm: 118, from: 1, to: 8 }, { psalm: 118, from: 9, to: 16 }, { psalm: 118, from: 17, to: 24 }, { psalm: 118, from: 25, to: 32 }] },
3
+ { hour: "Prima", weekday: 1, festis: false, psalms: [{ psalm: 1 }, { psalm: 2 }, { psalm: 6 }] },
4
+ { hour: "Prima", weekday: 2, festis: false, psalms: [{ psalm: 7 }, { psalm: 8 }, { psalm: 9, from: 2, to: 19 }] },
5
+ { hour: "Prima", weekday: 3, festis: false, psalms: [{ psalm: 9, from: 20, to: 39 }, { psalm: 10 }, { psalm: 11 }] },
6
+ { hour: "Prima", weekday: 4, festis: false, psalms: [{ psalm: 12 }, { psalm: 13 }, { psalm: 14 }] },
7
+ { hour: "Prima", weekday: 5, festis: false, psalms: [{ psalm: 15 }, { psalm: 16 }, { psalm: 17, from: 2, to: 25 }] },
8
+ { hour: "Prima", weekday: 6, festis: false, psalms: [{ psalm: 17, from: 26, to: 51 }, { psalm: 18 }, { psalm: 19 }] },
9
+ { hour: "Prima", weekday: null, festis: true, psalms: [{ psalm: 118, from: 1, to: 8 }, { psalm: 118, from: 9, to: 16 }, { psalm: 118, from: 17, to: 24 }, { psalm: 118, from: 25, to: 32 }] },
10
+ { hour: "Tertia", weekday: 0, festis: false, psalms: [{ psalm: 118, from: 33, to: 40 }, { psalm: 118, from: 41, to: 48 }, { psalm: 118, from: 49, to: 56 }] },
11
+ { hour: "Tertia", weekday: 1, festis: false, psalms: [{ psalm: 118, from: 105, to: 112 }, { psalm: 118, from: 113, to: 120 }, { psalm: 118, from: 121, to: 128 }] },
12
+ { hour: "Tertia", weekday: null, festis: false, psalms: [{ psalm: 119 }, { psalm: 120 }, { psalm: 121 }] },
13
+ { hour: "Sexta", weekday: 0, festis: false, psalms: [{ psalm: 118, from: 57, to: 64 }, { psalm: 118, from: 65, to: 72 }, { psalm: 118, from: 73, to: 80 }] },
14
+ { hour: "Sexta", weekday: 1, festis: false, psalms: [{ psalm: 118, from: 129, to: 136 }, { psalm: 118, from: 137, to: 144 }, { psalm: 118, from: 145, to: 152 }] },
15
+ { hour: "Sexta", weekday: null, festis: false, psalms: [{ psalm: 122 }, { psalm: 123 }, { psalm: 124 }] },
16
+ { hour: "Nona", weekday: 0, festis: false, psalms: [{ psalm: 118, from: 81, to: 88 }, { psalm: 118, from: 89, to: 96 }, { psalm: 118, from: 97, to: 104 }] },
17
+ { hour: "Nona", weekday: 1, festis: false, psalms: [{ psalm: 118, from: 153, to: 160 }, { psalm: 118, from: 161, to: 168 }, { psalm: 118, from: 169, to: 176 }] },
18
+ { hour: "Nona", weekday: null, festis: false, psalms: [{ psalm: 125 }, { psalm: 126 }, { psalm: 127 }] },
19
+ { hour: "Completorium", weekday: null, festis: false, psalms: [{ psalm: 4 }, { psalm: 90 }, { psalm: 133 }] }
20
+ ];
21
+ //# sourceMappingURL=office-psalms-monastic.js.map
@@ -12,4 +12,4 @@ export interface OfficePsalmEntry {
12
12
  psalms: OfficePsalmPortion[];
13
13
  }
14
14
  export declare const OFFICE_PSALMS: OfficePsalmEntry[];
15
- //# sourceMappingURL=office-psalms.d.ts.map
15
+ //# sourceMappingURL=office-psalms-roman.d.ts.map
@@ -1,7 +1,7 @@
1
- // office-psalms.ts — little-hours psalmody (Prime, Terce, Sext, None, Compline)
1
+ // office-psalms-roman.ts — Roman little-hours psalmody (Prime, Terce, Sext, None, Compline)
2
2
  // Extracted from Divinum Officium (Psalterium, [Tridentinum] section) by
3
3
  // scripts/extract-office-psalms.mjs
4
- // Generated: 2026-07-05T01:25:28.943Z
4
+ // Generated: 2026-07-08T04:45:29.223Z
5
5
  // Entries: 15
6
6
  //
7
7
  // The traditional (pre-1911) Roman little-hours psalm distribution. Each entry
@@ -25,4 +25,4 @@ export const OFFICE_PSALMS = [
25
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
26
  { hour: "Completorium", weekday: null, festis: false, psalms: [{ psalm: 4 }, { psalm: 30, from: 2, to: 6 }, { psalm: 90 }, { psalm: 133 }] }
27
27
  ];
28
- //# sourceMappingURL=office-psalms.js.map
28
+ //# sourceMappingURL=office-psalms-roman.js.map
@@ -1,8 +1,23 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // engines/cal/calendar — liturgical feast lookup
3
3
  // ---------------------------------------------------------------------------
4
+ // The era, and why it is what it is. The calendar's *structure* is medieval —
5
+ // the temporale from Advent through the season after Pentecost (Septuagesima
6
+ // included), the eight-hour office cursus, the duplex/semiduplex/simplex
7
+ // dignity system. The *data* is the Tridentine codification (1570–1962) drawn
8
+ // from Divinum Officium [biblio: divinum-officium], substantially continuous
9
+ // with late-medieval Roman usage but carrying feasts instituted as late as the
10
+ // 1950s (Queenship of Mary 1954, Immaculate Heart 1944).
11
+ //
12
+ // Decision: those post-medieval feasts are KEPT, not pruned. Pruning would
13
+ // break the DO data's integrity and demand per-feast historical adjudication;
14
+ // instead tonus states its actual era plainly. The honest description is
15
+ // "Tridentine Roman, continuous with medieval practice" — not "a medieval
16
+ // calendar." (Per-feast era metadata — medieval / tridentine / modern — is
17
+ // noted as future work.) The rank system this data carries is documented at
18
+ // `ritus`/`Grade` in ./types.ts; Easter reckoning at pascha() in ./date.ts.
4
19
  import { CAL } from "../../data/cal.js";
5
- import { MASSES } from "../../data/masses.js";
20
+ import { MASSES } from "../chant/data/masses.js";
6
21
  import { isoDate, startOfDay, addDays, subDays, firstSundayOnOrAfter, nextSunday, pascha, resolveEntryId, DEFAULT_EPOCH, } from "./date.js";
7
22
  import { TEMPUS_NAMES, entryGrade, gradeOrder, BVM_FEAST_IDS, APOSTOLIC_FEAST_IDS, } from "./types.js";
8
23
  const _calCache = new Map();
@@ -37,6 +37,11 @@ export function parseMonthDay(year, mmdd) {
37
37
  return new Date(Date.UTC(year, m - 1, d));
38
38
  }
39
39
  // ── Easter ──
40
+ // Split at the Gregorian reform (1583): from 1583 on, the Gauss/Butcher
41
+ // Gregorian computus; before it, the classical 19-year Julian cycle with a
42
+ // Julian→Gregorian day-number conversion [biblio: computus]. This keeps Easter
43
+ // correct for date queries reaching back into the medieval period — the era the
44
+ // calendar is built for (see the era note in ./calendar.ts).
40
45
  export function pascha(year) {
41
46
  if (year < 1583)
42
47
  return paschaJulian(year);
@@ -1,4 +1,10 @@
1
- import type { Chant, CantusQuery } from "./types.js";
1
+ import type { Chant, CantusQuery, ChantSource, Corpus } from "./types.js";
2
+ /**
3
+ * Metadata and content breakdown for one corpus book (`tonus.corpus`). Pass a
4
+ * source code; get the book's bibliographic identity plus its genre and mode
5
+ * distributions. Computed on first access from the loaded corpus, then cached.
6
+ */
7
+ export declare function getCorpus(code: ChantSource): Corpus;
2
8
  export declare function resolveChant(id: string | null): Chant | null;
3
9
  export declare function resolveChants(ids: string[]): Chant[];
4
10
  /**
@@ -1,8 +1,11 @@
1
1
  import { OFFICE_LABELS, MODE_LABELS } from "./types.js";
2
+ import { CORPUS_OVERLAP } from "../../data/corpus-overlap.js";
2
3
  import { GR_DATA, GR_SOURCE } from "../../data/gr.js";
3
4
  import { LU_DATA, LU_SOURCE } from "../../data/lu.js";
4
5
  import { LA_DATA, LA_SOURCE } from "../../data/la.js";
5
6
  import { LH_DATA, LH_SOURCE } from "../../data/lh.js";
7
+ import { AM_DATA, AM_SOURCE } from "../../data/am.js";
8
+ import { NR_DATA, NR_SOURCE } from "../../data/nocturnale-romanum.js";
6
9
  function modusOf(mode) {
7
10
  return mode != null ? (MODE_LABELS[mode] ?? null) : null;
8
11
  }
@@ -63,6 +66,8 @@ const CORPUS = [
63
66
  ...LU_DATA.map((c) => withLabels(c, LU_SOURCE)),
64
67
  ...LA_DATA.map((c) => withLabels(c, LA_SOURCE)),
65
68
  ...LH_DATA.map((c) => withLabels(c, LH_SOURCE)),
69
+ ...AM_DATA.map((c) => withLabels(c, AM_SOURCE)),
70
+ ...NR_DATA.map((c) => withLabels(c, NR_SOURCE)),
66
71
  ];
67
72
  let _byId = null;
68
73
  function byId() {
@@ -70,6 +75,73 @@ function byId() {
70
75
  _byId = new Map(CORPUS.map((c) => [c.id, c]));
71
76
  return _byId;
72
77
  }
78
+ const SOURCES = {
79
+ gr: GR_SOURCE, lu: LU_SOURCE, la: LA_SOURCE, lh: LH_SOURCE, am: AM_SOURCE, nr: NR_SOURCE,
80
+ };
81
+ // Tally a book's genre and mode distribution — computed once per code, cached.
82
+ const _corpusCache = new Map();
83
+ /**
84
+ * Metadata and content breakdown for one corpus book (`tonus.corpus`). Pass a
85
+ * source code; get the book's bibliographic identity plus its genre and mode
86
+ * distributions. Computed on first access from the loaded corpus, then cached.
87
+ */
88
+ export function getCorpus(code) {
89
+ const cached = _corpusCache.get(code);
90
+ if (cached)
91
+ return cached;
92
+ const src = SOURCES[code];
93
+ if (!src)
94
+ throw new Error(`Unknown corpus code: "${code}" (expected gr, lu, la, lh, or am)`);
95
+ const chants = CORPUS.filter((c) => c.source.code === code);
96
+ // Genre distribution — count by office code, descending by count.
97
+ const officeCounts = new Map();
98
+ // Mode distribution — count by mode 1–8; everything else (p/d/e, null) into one bucket.
99
+ const modeCounts = new Map();
100
+ let otherModes = 0;
101
+ for (const c of chants) {
102
+ officeCounts.set(c.office, (officeCounts.get(c.office) ?? 0) + 1);
103
+ if (c.mode != null && MODE_LABELS[c.mode]) {
104
+ modeCounts.set(c.mode, (modeCounts.get(c.mode) ?? 0) + 1);
105
+ }
106
+ else {
107
+ otherModes++;
108
+ }
109
+ }
110
+ const genera = [...officeCounts.entries()]
111
+ .map(([office, count]) => ({ office, genus: OFFICE_LABELS[office] ?? office, count }))
112
+ .sort((a, b) => b.count - a.count);
113
+ const modes = [];
114
+ for (const m of ["1", "2", "3", "4", "5", "6", "7", "8"]) {
115
+ const count = modeCounts.get(m);
116
+ if (count)
117
+ modes.push({ mode: m, modus: MODE_LABELS[m], count });
118
+ }
119
+ if (otherModes > 0)
120
+ modes.push({ mode: null, modus: null, count: otherModes });
121
+ // Pre-dedup relationships (precomputed in tonus-corpus — tonus can't derive
122
+ // them, since it stores only one copy of each shared chant).
123
+ const ov = CORPUS_OVERLAP[code];
124
+ const shared = Object.entries(ov?.shared ?? {})
125
+ .map(([c, count]) => ({ code: c, count }))
126
+ .sort((a, b) => b.count - a.count);
127
+ const result = {
128
+ code,
129
+ book: src.book,
130
+ fullTitle: src.fullTitle ?? null,
131
+ edition: src.edition ?? null,
132
+ year: src.year,
133
+ editor: src.editor,
134
+ scanSource: src.scanSource ?? null,
135
+ count: chants.length,
136
+ total: ov?.total ?? chants.length,
137
+ unique: ov?.unique ?? chants.length,
138
+ shared,
139
+ genera,
140
+ modes,
141
+ };
142
+ _corpusCache.set(code, result);
143
+ return result;
144
+ }
73
145
  function toArray(v) {
74
146
  if (v === undefined)
75
147
  return undefined;
@@ -1,4 +1,4 @@
1
- import type { Season } from "../engines/cal/types.js";
1
+ import type { Season } from "../../cal/types.js";
2
2
  /** The invariable spine — chants sung the same every night. */
3
3
  export declare const COMPLINE_ORDINARY: {
4
4
  /** Deus in adjutorium — the opening versicle. */
@@ -1,4 +1,4 @@
1
- import { type Season, type Grade } from "../engines/cal/types.js";
1
+ import { type Season, type Grade } from "../../cal/types.js";
2
2
  export interface MassEntry {
3
3
  id: string;
4
4
  mass: number;
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Describes which masses are appropriate for a given season, grade, and day.
4
4
  // Mass 0 has two ad-lib variants (index 0a/0b); all others are single entries.
5
- import { GRADE_ORDER, } from "../engines/cal/types.js";
5
+ import { GRADE_ORDER, } from "../../cal/types.js";
6
6
  // Inclusive slice of GRADE_ORDER from `high` (more solemn) to `low`.
7
7
  // e.g. gradesFrom("duplex-i", "duplex-ii") lists every grade between them.
8
8
  function gradesFrom(high, low) {
@@ -1,4 +1,4 @@
1
- import type { Season } from "../engines/cal/types.js";
1
+ import type { Season } from "../../cal/types.js";
2
2
  /** The invariable spine. */
3
3
  export declare const PRIME_ORDINARY: {
4
4
  /** Deus in adjutorium — the opening versicle. */
@@ -8,7 +8,7 @@
8
8
  // Solesmes chant and are not in the corpus, so they are out of scope; this is a
9
9
  // chant ordo, not a full Breviary Prime.
10
10
  //
11
- // Like data/compline.ts and data/masses.ts this is a hand-authored table that
11
+ // Like compline.ts and masses.ts (its siblings here) this is a hand-authored table that
12
12
  // references chants already in the corpus by id; it carries no GABC of its own.
13
13
  // See docs/chant.md and BIBLIOGRAPHY.md.
14
14
  // Prime's psalmody (Ps 53 + a weekday-proper psalm + Ps 118 in two sections,
@@ -6,10 +6,17 @@ import { intonePortion, officePsalmPortions } from "./psalm.js";
6
6
  import { temporaSundayId } from "../cal/date.js";
7
7
  import { getFeast } from "../cal/calendar.js";
8
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";
9
+ import { OFFICE_MONASTIC } from "../../data/office-monastic.js";
10
+ import { COMPLINE_ORDINARY, COMPLINE_SEASONAL, marianAntiphonFor, } from "./data/compline.js";
11
+ import { PRIME_ORDINARY, PRIME_SEASONAL } from "./data/prime.js";
11
12
  let _roman = null;
12
- function romanMap() {
13
+ let _monastic = null;
14
+ function officeMap(rite) {
15
+ if (rite === "monasticum") {
16
+ if (!_monastic)
17
+ _monastic = new Map(OFFICE_MONASTIC.map((d) => [d.feastId, d]));
18
+ return _monastic;
19
+ }
13
20
  if (!_roman)
14
21
  _roman = new Map(OFFICE_ROMAN.map((d) => [d.feastId, d]));
15
22
  return _roman;
@@ -29,14 +36,17 @@ const SEASONAL_ORDO_HOURS = new Set([
29
36
  // tables at all. The ordo is assembled from the season (Te lucis, In manus
30
37
  // tuas), the fixed psalms (from the extracted DO scheme), the invariable spine
31
38
  // (Deus in adjutorium, Nunc dimittis), and the date-driven Marian antiphon.
32
- // See data/compline.ts.
33
- function complineForFeast(feast) {
39
+ // See ./data/compline.ts.
40
+ function complineForFeast(feast, rite) {
34
41
  const seasonal = COMPLINE_SEASONAL[feast.season];
35
42
  const results = [];
36
43
  const opening = resolveChant(COMPLINE_ORDINARY.opening);
37
44
  if (opening)
38
45
  results.push(opening);
39
- for (const p of officePsalmPortions("Completorium", feast.weekday)) {
46
+ // Monastic Compline uses a fixed three-psalm set (4, 90, 133); the Roman rite
47
+ // adds Ps 30 vv. 2–6. The difference is entirely in the psalm scheme — the
48
+ // rest of the ordo (spine, hymn, In manus tuas, Marian antiphon) is shared.
49
+ for (const p of officePsalmPortions("Completorium", feast.weekday, rite)) {
40
50
  results.push(...intonePortion(p));
41
51
  }
42
52
  const hymn = seasonal && resolveChant(seasonal.teLucis);
@@ -54,9 +64,9 @@ function complineForFeast(feast) {
54
64
  return results;
55
65
  }
56
66
  // 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
67
+ // sung parts only (see ./data/prime.ts): opening, fixed psalms, the hymn Iam
58
68
  // lucis, and the seasonal short responsory Christe Fili Dei.
59
- function primeForFeast(feast) {
69
+ function primeForFeast(feast, rite) {
60
70
  const seasonal = PRIME_SEASONAL[feast.season];
61
71
  const results = [];
62
72
  const opening = resolveChant(PRIME_ORDINARY.opening);
@@ -65,7 +75,9 @@ function primeForFeast(feast) {
65
75
  const hymn = resolveChant(PRIME_ORDINARY.hymn);
66
76
  if (hymn)
67
77
  results.push(hymn);
68
- for (const p of officePsalmPortions("Prima", feast.weekday)) {
78
+ // The monastic Prime psalmody is weekday-varied across the psalter (vs. the
79
+ // Roman Ps-118 pattern) — a psalm-scheme difference; the ordo spine is shared.
80
+ for (const p of officePsalmPortions("Prima", feast.weekday, rite)) {
69
81
  results.push(...intonePortion(p));
70
82
  }
71
83
  const responsory = seasonal && resolveChant(seasonal.responsory);
@@ -73,12 +85,12 @@ function primeForFeast(feast) {
73
85
  results.push(responsory);
74
86
  return results;
75
87
  }
76
- function chantsForFeastHour(feast, hour) {
88
+ function chantsForFeastHour(feast, hour, rite) {
77
89
  if (hour === "completorium")
78
- return complineForFeast(feast);
90
+ return complineForFeast(feast, rite);
79
91
  if (hour === "prima")
80
- return primeForFeast(feast);
81
- const map = romanMap();
92
+ return primeForFeast(feast, rite);
93
+ const map = officeMap(rite);
82
94
  const sunday = temporaSundayId(feast.id);
83
95
  const day = map.get(feast.id) ?? (sunday ? (map.get(sunday) ?? null) : null);
84
96
  if (!day)
@@ -111,7 +123,7 @@ function chantsForFeastHour(feast, hour) {
111
123
  // repeat the psalms once per feast).
112
124
  if (feast.date) {
113
125
  const hourName = hour === "tertia" ? "Tertia" : hour === "sexta" ? "Sexta" : "Nona";
114
- for (const p of officePsalmPortions(hourName, feast.weekday)) {
126
+ for (const p of officePsalmPortions(hourName, feast.weekday, rite)) {
115
127
  results.push(...intonePortion(p));
116
128
  }
117
129
  }
@@ -147,34 +159,37 @@ export function getHour(query) {
147
159
  return [];
148
160
  const feasts = toArray(query.feast);
149
161
  const hour = query.hora;
162
+ const rite = query.rite ?? "romanum";
150
163
  let results;
151
164
  if (feasts && hour) {
152
165
  // Prime and Compline are seasonal/weekday ordos, identical for every feast
153
166
  // of the day — so concurrent feasts collapse to a single ordo rather than
154
167
  // repeating it. The other hours are genuinely per-feast.
155
168
  results = SEASONAL_ORDO_HOURS.has(hour)
156
- ? feasts[0] ? chantsForFeastHour(feasts[0], hour) : []
157
- : feasts.flatMap((f) => chantsForFeastHour(f, hour));
169
+ ? feasts[0] ? chantsForFeastHour(feasts[0], hour, rite) : []
170
+ : feasts.flatMap((f) => chantsForFeastHour(f, hour, rite));
158
171
  }
159
172
  else if (feasts) {
160
173
  const hours = [
161
174
  "matutinum", "laudes", "prima", "tertia", "sexta", "nona",
162
175
  "vesperae", "completorium",
163
176
  ];
164
- results = feasts.flatMap((f) => hours.flatMap((h) => chantsForFeastHour(f, h)));
177
+ results = feasts.flatMap((f) => hours.flatMap((h) => chantsForFeastHour(f, h, rite)));
165
178
  }
166
179
  else if (hour && SEASONAL_ORDO_HOURS.has(hour)) {
167
180
  // Prime and Compline are seasonal ordos, not per-feast. With no feast,
168
181
  // resolve for the default epoch (Guido d'Arezzo's era) — festum()'s anchor.
169
182
  const [feast] = getFeast();
170
- results = feast ? chantsForFeastHour(feast, hour) : [];
183
+ results = feast ? chantsForFeastHour(feast, hour, rite) : [];
171
184
  }
172
185
  else if (hour) {
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.
175
- results = OFFICE_ROMAN.flatMap((day) => {
186
+ // Hour without feast — survey per-feast content across the office entries of
187
+ // the chosen rite. mockFeast has no date, so the little hours return only
188
+ // their responsories.
189
+ const table = rite === "monasticum" ? OFFICE_MONASTIC : OFFICE_ROMAN;
190
+ results = table.flatMap((day) => {
176
191
  const mockFeast = { id: day.feastId };
177
- return chantsForFeastHour(mockFeast, hour);
192
+ return chantsForFeastHour(mockFeast, hour, rite);
178
193
  });
179
194
  }
180
195
  else {
@@ -1,8 +1,13 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // engines/chant/intone — GABC generation for psalm verses sung to psalm tones
3
3
  // ---------------------------------------------------------------------------
4
+ // Psalmody structure follows the Liber Usualis rules [biblio: liber-usualis]:
5
+ // each verse is intonation → tenor (reciting note) → mediation → final cadence,
6
+ // with the flex a subdivision of a long first half. The intonation is sung on
7
+ // the first verse only (unless repeated, as in the Magnificat) — see the
8
+ // `intonation` option; `inDirectum` recites straight through with no mediant.
4
9
  import { syllabifyPhrase } from "./syllabify.js";
5
- import { getTone, getDifferentia } from "../../data/tones.js";
10
+ import { getTone, getDifferentia } from "../temper/data/tones.js";
6
11
  import { midiToGabc } from "../temper/gabc.js";
7
12
  const DEFAULT_CLEF = "c4";
8
13
  const CLEF = `(${DEFAULT_CLEF}) `;
@@ -0,0 +1,33 @@
1
+ import type { Chant, OfficiumQuery } from "./types.js";
2
+ /** One nocturn of a resolved Matins: its responsories (and any antiphons). */
3
+ export interface Nocturn {
4
+ /** 1–3. */
5
+ n: number;
6
+ /** The nocturn's great responsories, in order, as resolved chants. */
7
+ responsories: Chant[];
8
+ /** The nocturn's antiphons, where the Nocturnale carries them (else empty). */
9
+ antiphons: Chant[];
10
+ }
11
+ /** A feast's structured Roman Matins. */
12
+ export interface Matins {
13
+ /** The tonus feast id this Matins was resolved for. */
14
+ feastId: string;
15
+ /** The feast's Latin name from the Nocturnale. */
16
+ name: string;
17
+ /** The rank/class, e.g. "I. classis", "Feria". */
18
+ rank: string;
19
+ /** One nocturn (simple) or three (festal). */
20
+ nocturns: Nocturn[];
21
+ /** The feast whose chants were borrowed by rubric, if any (provenance). */
22
+ redirectedFrom: string | null;
23
+ }
24
+ /**
25
+ * Structured Roman Matins (`tonus.matutinum`) for a feast: the nocturns with
26
+ * their responsories (and antiphons where present), assembled from the
27
+ * Nocturnale Romanum. Without a feast, resolves the default epoch's feast (as
28
+ * `officium` does). Only the Roman rite is served today; other rites return
29
+ * null (the monastic night office is not yet modelled — see office-matins-roman).
30
+ * @returns the Matins structure, or null when no Nocturnale match exists.
31
+ */
32
+ export declare function getMatins(query?: OfficiumQuery): Matins | null;
33
+ //# sourceMappingURL=matutinum.d.ts.map
@@ -0,0 +1,81 @@
1
+ // ---------------------------------------------------------------------------
2
+ // engines/chant/matutinum — structured Roman Matins (nocturns)
3
+ // ---------------------------------------------------------------------------
4
+ // The flat `officium({ hora: "matutinum" })` returns Matins chants as an
5
+ // undifferentiated Chant[] (the best-effort flat view, unchanged). This module
6
+ // adds the STRUCTURED night office for the Roman rite: the 3-nocturn assembly
7
+ // from the Nocturnale Romanum, joined to the tonus calendar by feast id.
8
+ //
9
+ // It is additive and separate — a distinct accessor (`tonus.matutinum`), not a
10
+ // reshape of getHour — so the flat path and every other rite stay as they are.
11
+ // Coverage is what the Nocturnale + the calendar bridge give: the sanctorale and
12
+ // Advent today (see office-matins-roman.ts). A feast with no Nocturnale match
13
+ // returns null, the office's graceful-degradation convention.
14
+ import { resolveChant } from "./chant.js";
15
+ import { getFeast } from "../cal/calendar.js";
16
+ import { temporaSundayId } from "../cal/date.js";
17
+ import { MATINS_ROMAN } from "../../data/office-matins-roman.js";
18
+ let _byFeast = null;
19
+ function byFeast() {
20
+ if (!_byFeast) {
21
+ _byFeast = new Map();
22
+ for (const d of MATINS_ROMAN)
23
+ if (d.tonusFeastId)
24
+ _byFeast.set(d.tonusFeastId, d);
25
+ }
26
+ return _byFeast;
27
+ }
28
+ function resolveDay(day) {
29
+ const nocturns = day.nocturns.map((noc) => {
30
+ const responsories = [];
31
+ const antiphons = [];
32
+ for (const c of noc.chants) {
33
+ const chant = resolveChant(c.id);
34
+ if (!chant)
35
+ continue;
36
+ if (c.type === "A")
37
+ antiphons.push(chant);
38
+ else
39
+ responsories.push(chant); // R (and I invitatory) resolve as responsories
40
+ }
41
+ return { n: noc.n, responsories, antiphons };
42
+ });
43
+ return {
44
+ feastId: day.tonusFeastId,
45
+ name: day.name,
46
+ rank: day.rank,
47
+ nocturns,
48
+ redirectedFrom: day.redirectedFrom,
49
+ };
50
+ }
51
+ /** The Nocturnale Matins for a feast id, or null when the calendar has no match. */
52
+ function matinsForFeastId(feastId) {
53
+ const map = byFeast();
54
+ const day = map.get(feastId) ?? (() => {
55
+ // Temporal feasts resolve their Sunday where a weekday has no proper.
56
+ const sunday = temporaSundayId(feastId);
57
+ return sunday ? map.get(sunday) : undefined;
58
+ })();
59
+ return day ? resolveDay(day) : null;
60
+ }
61
+ /**
62
+ * Structured Roman Matins (`tonus.matutinum`) for a feast: the nocturns with
63
+ * their responsories (and antiphons where present), assembled from the
64
+ * Nocturnale Romanum. Without a feast, resolves the default epoch's feast (as
65
+ * `officium` does). Only the Roman rite is served today; other rites return
66
+ * null (the monastic night office is not yet modelled — see office-matins-roman).
67
+ * @returns the Matins structure, or null when no Nocturnale match exists.
68
+ */
69
+ export function getMatins(query) {
70
+ const rite = query?.rite ?? "romanum";
71
+ if (rite !== "romanum")
72
+ return null;
73
+ const feasts = query?.feast
74
+ ? Array.isArray(query.feast) ? query.feast : [query.feast]
75
+ : null;
76
+ const feast = feasts ? feasts[0] : getFeast()[0];
77
+ if (!feast)
78
+ return null;
79
+ return matinsForFeastId(feast.id);
80
+ }
81
+ //# sourceMappingURL=matutinum.js.map
@@ -1,7 +1,7 @@
1
1
  // ---------------------------------------------------------------------------
2
2
  // engines/chant/ordinary — Mass ordinary (kyriale) selection
3
3
  // ---------------------------------------------------------------------------
4
- import { MASSES, AD_LIB } from "../../data/masses.js";
4
+ import { MASSES, AD_LIB } from "./data/masses.js";
5
5
  import { KYRIALE } from "../../data/kyriale.js";
6
6
  import { MODE_LABELS, ORDINARY_LABELS, } from "./types.js";
7
7
  import { gradeOrder, PENITENTIAL_SEASONS, } from "../cal/types.js";
@@ -1,5 +1,5 @@
1
- import { type OfficePsalmEntry, type OfficePsalmPortion } from "../../data/office-psalms.js";
2
- import { type Chant, type PsalmusQuery } from "./types.js";
1
+ import { type OfficePsalmEntry, type OfficePsalmPortion } from "../../data/office-psalms-roman.js";
2
+ import { type Chant, type PsalmusQuery, type Rite } from "./types.js";
3
3
  /**
4
4
  * Psalm and canticle retrieval (`tonus.psalmus`) from the Psalterium,
5
5
  * intoned to the psalm tones (modes 1-8 plus tonus peregrinus) as GABC.
@@ -16,9 +16,9 @@ export declare function getPsalmRange(psalm: number, lo: number, hi: number, mod
16
16
  export declare function intonePortion(p: OfficePsalmPortion, mode?: number): Chant[];
17
17
  /**
18
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
19
+ * the extracted DO Tridentine scheme (`office-psalms-roman.ts`). Prefers the
20
20
  * weekday-specific entry, then the ferial default (weekday null), then the
21
21
  * feast set; returns the psalm portions (not yet intoned).
22
22
  */
23
- export declare function officePsalmPortions(hour: OfficePsalmEntry["hour"], weekday: number): OfficePsalmPortion[];
23
+ export declare function officePsalmPortions(hour: OfficePsalmEntry["hour"], weekday: number, rite?: Rite): OfficePsalmPortion[];
24
24
  //# sourceMappingURL=psalm.d.ts.map
@@ -2,7 +2,8 @@
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
+ import { OFFICE_PSALMS, } from "../../data/office-psalms-roman.js";
6
+ import { OFFICE_PSALMS_MONASTIC } from "../../data/office-psalms-monastic.js";
6
7
  import { intone } from "./intone.js";
7
8
  import { MODE_LABELS } from "./types.js";
8
9
  const CANTICLE_NAMES = {
@@ -82,12 +83,13 @@ export function intonePortion(p, mode = 8) {
82
83
  }
83
84
  /**
84
85
  * 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
+ * the extracted DO Tridentine scheme (`office-psalms-roman.ts`). Prefers the
86
87
  * weekday-specific entry, then the ferial default (weekday null), then the
87
88
  * feast set; returns the psalm portions (not yet intoned).
88
89
  */
89
- export function officePsalmPortions(hour, weekday) {
90
- const forHour = OFFICE_PSALMS.filter((e) => e.hour === hour);
90
+ export function officePsalmPortions(hour, weekday, rite = "romanum") {
91
+ const scheme = rite === "monasticum" ? OFFICE_PSALMS_MONASTIC : OFFICE_PSALMS;
92
+ const forHour = scheme.filter((e) => e.hour === hour);
91
93
  const exact = forHour.find((e) => e.weekday === weekday && !e.festis);
92
94
  const ferial = forHour.find((e) => e.weekday === null && !e.festis);
93
95
  const festis = forHour.find((e) => e.festis);
@@ -2,7 +2,7 @@ import type { Season, Grade, Feast } from "../cal/types.js";
2
2
  export type { Season, Grade, Feast };
3
3
  export type OfficeCode = "an" | "al" | "ca" | "co" | "gr" | "hy" | "in" | "of" | "ps" | "re" | "rb" | "se" | "tr" | "tp" | "or";
4
4
  export type OrdinaryCode = "ky" | "gl" | "cr" | "sa" | "ag" | "be" | "it" | "as" | "va";
5
- export type ChantSource = "gr" | "lu" | "la" | "lh";
5
+ export type ChantSource = "gr" | "lu" | "la" | "lh" | "am" | "nr";
6
6
  export type CanonicalHour = "matutinum" | "laudes" | "prima" | "tertia" | "sexta" | "nona" | "vesperae" | "completorium";
7
7
  export declare const MODE_LABELS: Readonly<Record<string, string>>;
8
8
  export declare const OFFICE_LABELS: Readonly<Record<OfficeCode, string>>;
@@ -22,8 +22,11 @@ export interface Chant {
22
22
  }[];
23
23
  source: {
24
24
  book: string;
25
+ fullTitle?: string | null;
26
+ edition?: string | null;
25
27
  year: number | null;
26
28
  editor: string | null;
29
+ scanSource?: string | null;
27
30
  code?: ChantSource | "user";
28
31
  };
29
32
  ordinary?: OrdinaryCode;
@@ -35,6 +38,35 @@ export interface OrdinaryChant extends Chant {
35
38
  ordinarium: string;
36
39
  mass: number;
37
40
  }
41
+ export interface GenusCount {
42
+ office: OfficeCode;
43
+ genus: string;
44
+ count: number;
45
+ }
46
+ export interface ModeCount {
47
+ mode: string | null;
48
+ modus: string | null;
49
+ count: number;
50
+ }
51
+ export interface SharedCount {
52
+ code: ChantSource;
53
+ count: number;
54
+ }
55
+ export interface Corpus {
56
+ code: ChantSource;
57
+ book: string;
58
+ fullTitle: string | null;
59
+ edition: string | null;
60
+ year: number | null;
61
+ editor: string | null;
62
+ scanSource: string | null;
63
+ count: number;
64
+ total: number;
65
+ unique: number;
66
+ shared: SharedCount[];
67
+ genera: GenusCount[];
68
+ modes: ModeCount[];
69
+ }
38
70
  export interface CantusQuery {
39
71
  id?: string | string[];
40
72
  gabc?: string;
@@ -54,9 +86,13 @@ export interface OrdinariumQuery extends CantusQuery {
54
86
  ordinary?: OrdinaryCode;
55
87
  mass?: number;
56
88
  }
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";
57
92
  export interface OfficiumQuery extends CantusQuery {
58
93
  feast?: Feast | Feast[];
59
94
  hora?: CanonicalHour;
95
+ rite?: Rite;
60
96
  }
61
97
  export interface PsalmusQuery {
62
98
  psalm?: number | string;