tonus 0.1.8 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/BIBLIOGRAPHY.md +132 -108
  2. package/CHANGELOG.md +598 -1
  3. package/LICENSE +133 -29
  4. package/README.md +106 -83
  5. package/dist/data/am.js +2666 -11196
  6. package/dist/data/ams.d.ts +5 -0
  7. package/dist/data/ams.js +122 -0
  8. package/dist/data/attestation.d.ts +19 -0
  9. package/dist/data/attestation.js +15716 -0
  10. package/dist/data/attestation.json +15711 -0
  11. package/dist/data/cadentiae.d.ts +43 -0
  12. package/dist/data/cadentiae.js +174 -0
  13. package/dist/data/cal.js +48 -0
  14. package/dist/data/census.d.ts +12 -0
  15. package/dist/data/census.js +36 -0
  16. package/dist/data/commune-office.d.ts +4 -0
  17. package/dist/data/commune-office.js +2371 -0
  18. package/dist/data/commune-office.json +2365 -0
  19. package/dist/data/commune.js +181 -11
  20. package/dist/data/corpus-overlap.d.ts +17 -0
  21. package/dist/data/corpus-overlap.js +299 -5
  22. package/dist/data/cot.d.ts +5 -0
  23. package/dist/data/cot.js +172 -0
  24. package/dist/data/cse.d.ts +5 -0
  25. package/dist/data/cse.js +122 -0
  26. package/dist/data/gabc-glyphs.d.ts +45 -0
  27. package/dist/data/gabc-glyphs.js +122 -0
  28. package/dist/data/gr.js +754 -6394
  29. package/dist/data/kyriale.js +116 -116
  30. package/dist/data/la.js +799 -13419
  31. package/dist/data/lh.js +113 -3473
  32. package/dist/data/lu.js +931 -17631
  33. package/dist/data/nocturnale-romanum.js +1659 -10411
  34. package/dist/data/office-ferial.d.ts +4 -0
  35. package/dist/data/office-ferial.js +396 -0
  36. package/dist/data/office-ferial.json +391 -0
  37. package/dist/data/office-monastic.d.ts +17 -1
  38. package/dist/data/office-monastic.js +1403 -466
  39. package/dist/data/office-psalms-monastic.d.ts +13 -1
  40. package/dist/data/office-psalms-monastic.js +9 -0
  41. package/dist/data/propers.js +1 -1
  42. package/dist/data/psalms.js +22919 -5
  43. package/dist/data/psm.d.ts +5 -0
  44. package/dist/data/psm.js +122 -0
  45. package/dist/data/seasonal-respbreve.d.ts +5 -0
  46. package/dist/data/seasonal-respbreve.js +41 -0
  47. package/dist/data/seasonal-respbreve.json +35 -0
  48. package/dist/data/smufl-glyphs.d.ts +17 -0
  49. package/dist/data/smufl-glyphs.js +1546 -0
  50. package/dist/data/smufl-glyphs.json +1530 -0
  51. package/dist/engines/cal/calendar.d.ts +3 -2
  52. package/dist/engines/cal/calendar.js +105 -29
  53. package/dist/engines/cal/data/eras.d.ts +35 -0
  54. package/dist/engines/cal/data/eras.js +128 -0
  55. package/dist/engines/cal/date.js +44 -0
  56. package/dist/engines/cal/types.d.ts +15 -3
  57. package/dist/engines/cal/types.js +5 -5
  58. package/dist/engines/census/census.d.ts +7 -0
  59. package/dist/engines/census/census.js +179 -0
  60. package/dist/engines/census/types.d.ts +55 -0
  61. package/dist/engines/census/types.js +8 -0
  62. package/dist/engines/chant/attest.d.ts +39 -0
  63. package/dist/engines/chant/attest.js +90 -0
  64. package/dist/engines/chant/chant.d.ts +16 -4
  65. package/dist/engines/chant/chant.js +220 -30
  66. package/dist/engines/chant/data/compline.js +2 -1
  67. package/dist/engines/chant/data/masses.d.ts +56 -4
  68. package/dist/engines/chant/data/masses.js +305 -80
  69. package/dist/engines/chant/data/prime.js +1 -1
  70. package/dist/engines/chant/hour.js +279 -58
  71. package/dist/engines/chant/ordinary.d.ts +2 -0
  72. package/dist/engines/chant/ordinary.js +336 -56
  73. package/dist/engines/chant/propers.js +55 -5
  74. package/dist/engines/chant/psalm.d.ts +4 -4
  75. package/dist/engines/chant/psalm.js +25 -11
  76. package/dist/engines/chant/syllabify.d.ts +1 -0
  77. package/dist/engines/chant/syllabify.js +90 -17
  78. package/dist/engines/chant/types.d.ts +115 -12
  79. package/dist/engines/chant/types.js +38 -3
  80. package/dist/engines/harmonia/api.js +4 -0
  81. package/dist/engines/harmonia/data/doctrines.js +3 -1
  82. package/dist/engines/harmonia/tabula.d.ts +3 -0
  83. package/dist/engines/harmonia/tabula.js +1 -0
  84. package/dist/engines/harmonia/voice.d.ts +4 -0
  85. package/dist/engines/harmonia/voice.js +8 -4
  86. package/dist/engines/imprint.js +14 -1
  87. package/dist/engines/planet/orbital.js +4 -4
  88. package/dist/engines/planet/planet.d.ts +10 -0
  89. package/dist/engines/planet/planet.js +30 -3
  90. package/dist/engines/planet/position.js +13 -10
  91. package/dist/engines/planet/types.d.ts +1 -0
  92. package/dist/engines/score/api.d.ts +2 -13
  93. package/dist/engines/score/api.js +21 -8
  94. package/dist/engines/score/articulation.js +2 -2
  95. package/dist/engines/score/cadence.d.ts +76 -0
  96. package/dist/engines/score/cadence.js +96 -0
  97. package/dist/engines/score/emitters/accidentals.d.ts +21 -0
  98. package/dist/engines/score/emitters/accidentals.js +88 -0
  99. package/dist/engines/score/emitters/atramentum.d.ts +107 -0
  100. package/dist/engines/score/emitters/atramentum.js +239 -0
  101. package/dist/engines/score/emitters/breaking.d.ts +62 -0
  102. package/dist/engines/score/emitters/breaking.js +80 -0
  103. package/dist/engines/score/emitters/moderna.d.ts +38 -0
  104. package/dist/engines/score/emitters/moderna.js +612 -0
  105. package/dist/engines/score/emitters/svg.d.ts +143 -0
  106. package/dist/engines/score/emitters/svg.js +1328 -0
  107. package/dist/engines/score/emitters/tracks.d.ts +104 -0
  108. package/dist/engines/score/emitters/tracks.js +728 -0
  109. package/dist/engines/score/infer.d.ts +3 -3
  110. package/dist/engines/score/infer.js +2 -2
  111. package/dist/engines/score/inscriptio.d.ts +69 -0
  112. package/dist/engines/score/inscriptio.js +138 -0
  113. package/dist/engines/score/ir.d.ts +2 -2
  114. package/dist/engines/score/ir.js +59 -12
  115. package/dist/engines/score/lyric.d.ts +23 -0
  116. package/dist/engines/score/lyric.js +234 -0
  117. package/dist/engines/score/meta.d.ts +2 -2
  118. package/dist/engines/score/modulation.d.ts +12 -0
  119. package/dist/engines/score/modulation.js +49 -0
  120. package/dist/engines/score/neume.js +35 -4
  121. package/dist/engines/score/parse.js +150 -9
  122. package/dist/engines/score/phrasing.js +4 -3
  123. package/dist/engines/score/prosody.d.ts +38 -0
  124. package/dist/engines/score/prosody.js +70 -6
  125. package/dist/engines/score/tabula.d.ts +37 -5
  126. package/dist/engines/score/tabula.js +18 -0
  127. package/dist/engines/score/types.d.ts +88 -1
  128. package/dist/engines/temper/api.d.ts +4 -1
  129. package/dist/engines/temper/api.js +28 -5
  130. package/dist/engines/temper/data/guido.js +6 -2
  131. package/dist/engines/temper/data/modes.d.ts +6 -0
  132. package/dist/engines/temper/data/modes.js +42 -0
  133. package/dist/engines/temper/data/tones.d.ts +1 -1
  134. package/dist/engines/temper/data/tones.js +20 -11
  135. package/dist/engines/temper/interval.js +4 -3
  136. package/dist/engines/temper/modality.d.ts +11 -2
  137. package/dist/engines/temper/modality.js +74 -2
  138. package/dist/engines/temper/modes.d.ts +1 -1
  139. package/dist/engines/temper/pitch.d.ts +1 -1
  140. package/dist/engines/temper/pitch.js +12 -2
  141. package/dist/engines/temper/scale.d.ts +53 -0
  142. package/dist/engines/temper/scale.js +107 -8
  143. package/dist/index.d.ts +26 -8
  144. package/dist/index.js +37 -4
  145. package/docs/api/calendar.md +279 -0
  146. package/docs/api/census.md +288 -0
  147. package/docs/api/chant.md +657 -0
  148. package/docs/api/heavens.md +346 -0
  149. package/docs/api/index.md +263 -0
  150. package/docs/api/score.md +873 -0
  151. package/docs/api/tuning.md +619 -0
  152. package/package.json +11 -5
  153. package/dist/data/office-matins-roman.d.ts +0 -19
  154. package/dist/data/office-matins-roman.js +0 -4383
  155. package/dist/data/office-psalms-roman.d.ts +0 -15
  156. package/dist/data/office-psalms-roman.js +0 -28
  157. package/dist/data/office-roman.d.ts +0 -19
  158. package/dist/data/office-roman.js +0 -13792
  159. package/dist/engines/chant/matutinum.d.ts +0 -33
  160. package/dist/engines/chant/matutinum.js +0 -81
  161. package/dist/engines/score/emitters/midi.d.ts +0 -65
  162. package/dist/engines/score/emitters/midi.js +0 -162
  163. package/dist/engines/score/emitters/musicxml.d.ts +0 -18
  164. package/dist/engines/score/emitters/musicxml.js +0 -166
@@ -1,4 +1,4 @@
1
- import type { ChantType, Score } from "./types.js";
2
- export declare function inferChantType(ir: Score): ChantType | undefined;
3
- export declare function inferMode(ir: Score): number | undefined;
1
+ import type { ChantType, ScoreIR } from "./types.js";
2
+ export declare function inferChantType(ir: ScoreIR): ChantType | undefined;
3
+ export declare function inferMode(ir: ScoreIR): number | undefined;
4
4
  //# sourceMappingURL=infer.d.ts.map
@@ -1,4 +1,4 @@
1
- import { OFFICE_LABELS } from "../chant/types.js";
1
+ import { OFFICIA } from "../chant/types.js";
2
2
  import { MODES } from "../temper/modes.js";
3
3
  // Identify an ordinary chant by its opening words. Two movements have a second,
4
4
  // interior incipit because they are conventionally intoned from their second
@@ -15,7 +15,7 @@ const ORDINARY_INCIPITS = [
15
15
  [/^benedica/i, "be"],
16
16
  [/^ite/i, "it"],
17
17
  ];
18
- const OFFICE_CODES = new Set(Object.keys(OFFICE_LABELS));
18
+ const OFFICE_CODES = new Set(Object.keys(OFFICIA));
19
19
  export function inferChantType(ir) {
20
20
  const officePart = ir.chant.office?.toLowerCase().trim();
21
21
  if (officePart && OFFICE_CODES.has(officePart))
@@ -0,0 +1,69 @@
1
+ import { type NoteGeometry, type Theme } from "./emitters/svg.js";
2
+ import type { TrackName } from "./emitters/tracks.js";
3
+ import type { Score } from "./api.js";
4
+ export interface InscriptioOpts {
5
+ /** Notation species. Square-note by default; "moderna" is the modern transcription. */
6
+ notation?: "quadrata" | "moderna";
7
+ /** Accidental channel. "standard" performance accidentals by default. */
8
+ accidentals?: "standard" | "heji" | "cents";
9
+ /** Baseline for the cents channel; the chant's home intonation by default. */
10
+ centsBaseline?: "pythagorean" | "et";
11
+ /**
12
+ * Analysis tracks drawn beneath each system: "chironomia" — the rhythmic
13
+ * band (wave, Pierik letters) — and "tonarium" — the melodic band (maneriae
14
+ * rails, pressure sparkline, mode line, cadence nodes). Either rides either
15
+ * species, and both may ride one score; they stack in a fixed order, the
16
+ * chironomia above the tonarium, whichever order they are asked for.
17
+ *
18
+ * The two-register principle is the house pairing — the rhythmic band under
19
+ * the square notation (the body), the melodic band under the transcription
20
+ * (the mind). It is a default worth keeping, not a constraint enforced here.
21
+ */
22
+ tracks?: readonly TrackName[];
23
+ /** Wrap systems to this px width. Absent = a single system (current behaviour). */
24
+ width?: number;
25
+ /**
26
+ * How big the chant is drawn: `"small"`, `"normal"` (default), `"large"`, or
27
+ * a staff height in px for fitting a known column. Everything scales from it
28
+ * — notes, lyrics, the gap between systems — and it reflows the music, so a
29
+ * larger scale means fewer notes per line.
30
+ */
31
+ scale?: "small" | "normal" | "large" | number;
32
+ title?: string;
33
+ rubric?: string;
34
+ /** Derive the rubric block from chant meta (feast / genus / modus / source). */
35
+ annotation?: "auto";
36
+ /** Draw a rubricated initial from the first lyric; the first system indents. */
37
+ dropcap?: boolean;
38
+ /**
39
+ * Faces, ink, and measurements — the whole look in one object.
40
+ *
41
+ * `fonts` carries four roles (`dropcap`, `title`, `annotation`, `lyric`), each
42
+ * a family string or `{ family, weight?, scale?, embed? }`. Without `embed`
43
+ * the SVG carries a font-family REFERENCE and the host page supplies the face;
44
+ * with it the CALLER's bytes ride inside the SVG's own `<style>` and the file
45
+ * is self-contained. tonus bundles no font files — with `embed` it is a
46
+ * conduit for data the consumer supplies, and the consumer carries the face's
47
+ * license terms.
48
+ *
49
+ * `colors` reaches the SVG as CSS custom properties with the theme's value as
50
+ * the fallback, so a host stylesheet can retheme a drawn chant without
51
+ * re-rendering it. `metrics` cannot work that way: staff height and note scale
52
+ * are consumed by line breaking long before a stylesheet sees the output.
53
+ */
54
+ theme?: Theme;
55
+ }
56
+ export interface Inscriptio {
57
+ svg: string;
58
+ geometry: NoteGeometry[];
59
+ }
60
+ /**
61
+ * Render a `Score` as SVG. Returns the markup plus a geometry array (one entry
62
+ * per note, tabula order) for downstream analysis tracks.
63
+ * @throws Error when `score` is not a valid Score (a builder-function contract).
64
+ */
65
+ export declare function inscriptio(score: Score, opts?: InscriptioOpts): Inscriptio;
66
+ export type { NoteGeometry } from "./emitters/svg.js";
67
+ export type { FontSpec, FontSlot, FontEmbed, Theme, ThemeColors } from "./emitters/svg.js";
68
+ export type { TrackName, TrackData } from "./emitters/tracks.js";
69
+ //# sourceMappingURL=inscriptio.d.ts.map
@@ -0,0 +1,138 @@
1
+ // ---------------------------------------------------------------------------
2
+ // engines/score/inscriptio — the score renderer (SVG + geometry contract)
3
+ // ---------------------------------------------------------------------------
4
+ // `inscriptio` (the inscribing) draws a Score. Per the rendering boundary
5
+ // (CODE-STANDARDS → Boundaries), rendering is a standalone function that TAKES a
6
+ // score — not a method on one. It inks the score itself; the analysis tracks
7
+ // (chironomia, the rhythmic band; tonarium, the melodic one) ship with it as
8
+ // opt-in bands (`tracks`), either riding either species, drawn from the same
9
+ // placements the geometry contract exports — never by scraping the notation.
10
+ // Custom tracks remain downstream consumers of that contract. One emitter
11
+ // format: SVG.
12
+ //
13
+ // The result is `{ svg, geometry }`. The geometry array — one entry per note in
14
+ // tabula order — is the TRACK CONTRACT: downstream consumers place marks against
15
+ // notes by index and coordinate, never by scraping the SVG.
16
+ //
17
+ // This is the standalone shell over the species emitters: the square-note
18
+ // renderer (emitters/svg.ts) and the modern transcription (emitters/moderna.ts).
19
+ // The multi-system layout engine, front matter, and the accidental channels
20
+ // are all wired in through InscriptioOpts.
21
+ import { toSvg, } from "./emitters/svg.js";
22
+ import { toModerna } from "./emitters/moderna.js";
23
+ // Options handed through to the species emitters — everything but the species
24
+ // selector itself.
25
+ const EMITTER_KEYS = [
26
+ "width",
27
+ "title", "rubric", "annotation", "dropcap",
28
+ "accidentals", "centsBaseline", "tracks",
29
+ ];
30
+ /**
31
+ * Flatten a `theme` into the keys the emitters consume.
32
+ *
33
+ * The emitters take a flat option bag — one field per measurement, one per
34
+ * colour — because that is the shape a render loop wants. The theme is the
35
+ * shape a CALLER wants: faces, ink, and metrics travel together, and a house
36
+ * style is worth naming once. Resolving between them is this function's whole
37
+ * job, and it is the only place that knows both shapes.
38
+ */
39
+ function flattenTheme(theme) {
40
+ if (!theme)
41
+ return {};
42
+ const out = {};
43
+ if (theme.fonts)
44
+ out.fonts = theme.fonts;
45
+ const c = theme.colors;
46
+ if (c?.note !== undefined)
47
+ out.noteColor = c.note;
48
+ if (c?.staffLine !== undefined)
49
+ out.staffLineColor = c.staffLine;
50
+ if (c?.rubrica !== undefined)
51
+ out.rubricaColor = c.rubrica;
52
+ return out;
53
+ }
54
+ /** The named sizes, as staff heights in px. `normal` is the default 40. */
55
+ const SCALES = {
56
+ small: 30,
57
+ normal: 40,
58
+ large: 56,
59
+ };
60
+ /**
61
+ * Resolve `scale` to the emitter's `staffHeight`.
62
+ *
63
+ * A caller decides how big the chant should be, not how tall its staff is in
64
+ * pixels — so the public option is a size and the staff height is what it
65
+ * resolves to. A raw number still works for the caller who is fitting a known
66
+ * column, and means exactly what `staffHeight` meant.
67
+ */
68
+ function resolveScale(scale) {
69
+ if (scale === undefined)
70
+ return undefined;
71
+ if (typeof scale === "number")
72
+ return scale;
73
+ const px = SCALES[scale];
74
+ if (px === undefined) {
75
+ throw new Error(`inscriptio: unknown scale "${scale}" ` +
76
+ `(expected ${Object.keys(SCALES).join(", ")}, or a staff height in px)`);
77
+ }
78
+ return px;
79
+ }
80
+ /**
81
+ * Render a `Score` as SVG. Returns the markup plus a geometry array (one entry
82
+ * per note, tabula order) for downstream analysis tracks.
83
+ * @throws Error when `score` is not a valid Score (a builder-function contract).
84
+ */
85
+ export function inscriptio(score, opts = {}) {
86
+ if (!score || !Array.isArray(score.tabula)) {
87
+ throw new Error("inscriptio: expected a Score (from notatio)");
88
+ }
89
+ if (opts.notation && opts.notation !== "quadrata" && opts.notation !== "moderna") {
90
+ throw new Error(`inscriptio: unknown notation "${opts.notation}"`);
91
+ }
92
+ // The HEJI and cents channels are modern analytical overlays; they belong on
93
+ // the modern staff, not on historical square notation. Quadrata carries only
94
+ // the accidentals GABC itself expresses (flat/natural/sharp).
95
+ if (opts.notation !== "moderna" &&
96
+ (opts.accidentals === "heji" || opts.accidentals === "cents")) {
97
+ throw new Error(`inscriptio: accidentals "${opts.accidentals}" is a moderna-only intonation ` +
98
+ `overlay; square notation (quadrata) carries only GABC accidentals. ` +
99
+ `Use notation: "moderna".`);
100
+ }
101
+ // Any track rides either species, and all may ride one score — the
102
+ // selection is independent of the notation, as the notation itself is. An
103
+ // unknown name is a caller bug and throws.
104
+ for (const track of opts.tracks ?? []) {
105
+ if (track !== "prosodia" && track !== "chironomia" && track !== "tonarium") {
106
+ throw new Error(`inscriptio: unknown track "${track}" — tracks are "prosodia" ` +
107
+ `(the word band), "chironomia" (the rhythmic band) and "tonarium" ` +
108
+ `(the melodic band)`);
109
+ }
110
+ }
111
+ // Pass through the options the emitters consume; the species selector stays
112
+ // here. A species ignores, not errors on, options that do not apply to it.
113
+ // Both species honour the front matter (title, rubric/annotation) — the
114
+ // official opening is `title` + `annotation: "auto"`, no dropcap.
115
+ const emitterOpts = flattenTheme(opts.theme);
116
+ const staffHeight = resolveScale(opts.scale);
117
+ if (staffHeight !== undefined)
118
+ emitterOpts.staffHeight = staffHeight;
119
+ for (const k of EMITTER_KEYS)
120
+ if (opts[k] !== undefined)
121
+ emitterOpts[k] = opts[k];
122
+ // The tracks consume score-level analysis the flat tabula does not carry;
123
+ // hand it through only when a track asked for it.
124
+ if (opts.tracks && opts.tracks.length > 0) {
125
+ const modeDigit = score.chant.mode ? parseInt(score.chant.mode) : NaN;
126
+ const trackData = {
127
+ cadences: score.cadences,
128
+ modulations: score.modulations,
129
+ mode: Number.isFinite(modeDigit) ? modeDigit : undefined,
130
+ };
131
+ emitterOpts["trackData"] = trackData;
132
+ }
133
+ // Dispatch to the species' own renderer — each owns its spacing pass.
134
+ const render = opts.notation === "moderna" ? toModerna : toSvg;
135
+ const result = render(score.tabula, score.chant, emitterOpts);
136
+ return { svg: result.svg, geometry: result.geometry };
137
+ }
138
+ //# sourceMappingURL=inscriptio.js.map
@@ -1,5 +1,5 @@
1
- import type { CompoundBeat, RhythmicType, Score, ParseResult } from "./types.js";
1
+ import type { CompoundBeat, RhythmicType, ScoreIR, ParseResult } from "./types.js";
2
2
  import type { Scale } from "../temper/scale.js";
3
3
  export declare function classifyRhythmicType(beats: CompoundBeat[]): RhythmicType;
4
- export declare function buildIR(parsed: ParseResult, chant: Score["chant"], scale: Scale): Score;
4
+ export declare function buildIR(parsed: ParseResult, chant: ScoreIR["chant"], scale: Scale): ScoreIR;
5
5
  //# sourceMappingURL=ir.d.ts.map
@@ -4,8 +4,14 @@ import { selectVowel } from "../chant/syllabify.js";
4
4
  import { classifyNeume } from "./neume.js";
5
5
  function rawToNote(raw, scale) {
6
6
  const midi = raw.step;
7
+ // One read of the lyric: the nucleus and the diphthong it belongs to.
8
+ const sung = selectVowel(raw.lyric);
7
9
  return {
8
- pitch: toPitch(midi, scale),
10
+ // Spell the note as the SOURCE wrote it: a GABC `x` is a flat, `#` a sharp.
11
+ // Without the hint the speller reads the pitch class alone and calls a
12
+ // written D-flat "C#" — the same note, the opposite spelling, and `acc`
13
+ // reported +1 for a source that wrote a flat.
14
+ pitch: toPitch(midi, scale, raw.accidental === -1 ? "flat" : raw.accidental === 1 ? "sharp" : undefined),
9
15
  step: toStep(midi, scale),
10
16
  performance: {
11
17
  velocity: 0,
@@ -15,11 +21,22 @@ function rawToNote(raw, scale) {
15
21
  },
16
22
  context: {
17
23
  lyric: raw.lyric,
18
- vowel: selectVowel(raw.lyric).vowel,
24
+ runs: raw.runs,
25
+ vowel: sung.vowel,
26
+ diphthong: sung.diphthong,
19
27
  syllableIndex: raw.syllableIndex,
28
+ accent: raw.accent,
20
29
  neumeGroup: raw.neumeGroup,
30
+ lineBreak: raw.lineBreak,
31
+ keepWithPrev: raw.keepWithPrev,
32
+ staffLetter: raw.staffLetter,
33
+ clef: raw.clef,
34
+ shape: raw.shape,
21
35
  ictus: raw.ictus,
36
+ ictusSign: raw.ictusSign,
37
+ episema: raw.episema,
22
38
  accidentalSource: raw.accidentalSource,
39
+ accidentalSign: raw.accidentalSign,
23
40
  quilisma: raw.quilisma,
24
41
  liquescent: raw.liquescent,
25
42
  strophicus: raw.strophicus,
@@ -29,16 +46,33 @@ function rawToNote(raw, scale) {
29
46
  },
30
47
  };
31
48
  }
32
- // The salicus ictus note (its second-to-last ascending note) is prolonged
33
- // [biblio: sunol-textbook, Ch. V]. Modest, in the spirit of an episema.
49
+ // The salicus's principal note is prolonged. Modest, in the spirit of an
50
+ // episema.
51
+ //
52
+ // WHICH note is Cardine's correction [biblio: cardine-semiology, ch. 16 §II].
53
+ // The printed editions mark the note bearing the oriscus and the current
54
+ // practice lengthens it, treating it as principal "because of its special
55
+ // sign"; the manuscripts show the principal note is the one IMMEDIATELY
56
+ // FOLLOWING the oriscus — the summit — with §II.A proving it from the St Gall
57
+ // signs and the melodic tension toward that summit. The oriscus is a note of
58
+ // passage, not of rest. This is the one point where the rhythmic layer
59
+ // departs from Mocquereau/Suñol, and it departs deliberately.
60
+ //
61
+ // The summit is the LAST note of the ascent, because a salicus is ascending
62
+ // by definition and the oriscus is its next-to-last note — so the note after
63
+ // the oriscus is the final one, at any length. The 4- and 5-note forms need no
64
+ // separate rule.
34
65
  const SALICUS_PROLONGATION = 1.3;
35
66
  function makeSyllable(lyric, notes) {
36
67
  const neume = classifyNeume(notes);
37
68
  if (neume.type === "salicus" && notes.length >= 2) {
38
- const ictic = notes[notes.length - 2];
39
- ictic.performance.duration *= SALICUS_PROLONGATION;
69
+ const summit = notes[notes.length - 1];
70
+ summit.performance.duration *= SALICUS_PROLONGATION;
40
71
  }
41
- return { lyric, notes, neume };
72
+ const runs = notes[0]?.context.runs;
73
+ return runs
74
+ ? { lyric, runs, notes, neume, melisma: notes.length }
75
+ : { lyric, notes, neume, melisma: notes.length };
42
76
  }
43
77
  function partitionByIctus(annotated) {
44
78
  const groups = [];
@@ -140,7 +174,8 @@ function classifyCompoundBeats(annotated) {
140
174
  // only when the sequence genuinely fits; ambiguous shapes stay null (a wrong
141
175
  // analytic label is worse than none). Contraction (Type VIII) is Suñol's local
142
176
  // reading [biblio: sunol-textbook]: a thesis immediately followed by an arsis
143
- // mid-incise means two simple rhythms overlap at a shared ictus.
177
+ // mid-incise means two simple rhythms overlap at a shared ictus — and, being two
178
+ // *complete* rhythms, the incise still resolves thetic; an unresolved seam is null.
144
179
  export function classifyRhythmicType(beats) {
145
180
  if (beats.length < 2)
146
181
  return null; // a lone beat has no chaining; needs ≥ A–T
@@ -163,8 +198,16 @@ export function classifyRhythmicType(beats) {
163
198
  return null;
164
199
  }
165
200
  // There is at least one thesis→arsis seam, so the incise chains multiple simple
166
- // rhythms. Strict alternation A–T–A–T(…) is Carroll's Type VII; any other seam
167
- // pattern is a contraction (Suñol) — Carroll's Type VIII.
201
+ // rhythms — and a chain of *complete* rhythms must still resolve (end thetic).
202
+ // In juxtaposition "each thesis marks the end of one and each arsis the
203
+ // beginning of another" [biblio: sunol-textbook], so a chain left hanging arsic
204
+ // (A–T–A, A–T–A–T–A) has opened a rhythm it never closes: null, whatever the
205
+ // seam pattern. This gate applies to VII exactly as to VIII.
206
+ if (seq[seq.length - 1] !== "thetic")
207
+ return null;
208
+ // Strict alternation A–T–A–T(…) is Carroll's Type VII; any other seam
209
+ // pattern is a contraction (Suñol) — Carroll's Type VIII, two (or more)
210
+ // complete rhythms overlapping at a shared ictus.
168
211
  const alternating = seq.every((s, i) => s === (i % 2 === 0 ? "arsic" : "thetic"));
169
212
  if (alternating && seq.length >= 4)
170
213
  return "VII";
@@ -180,11 +223,15 @@ function applyCompoundBeats(phrases) {
180
223
  }
181
224
  phrase.beats = classifyCompoundBeats(annotated);
182
225
  phrase.rhythmicType = classifyRhythmicType(phrase.beats);
226
+ // Phrase-level conveniences: a sung syllable carries at least one note.
227
+ const sung = phrase.syllables.filter((s) => s.notes.length > 0);
228
+ phrase.syllableCount = sung.length;
229
+ phrase.noteCount = sung.reduce((n, s) => n + s.notes.length, 0);
183
230
  }
184
231
  }
185
232
  export function buildIR(parsed, chant, scale) {
186
233
  const phrases = [];
187
- let currentPhrase = { syllables: [], beats: [], rhythmicType: null };
234
+ let currentPhrase = { syllables: [], noteCount: 0, syllableCount: 0, beats: [], rhythmicType: null };
188
235
  let currentNotes = [];
189
236
  let currentLyric = null;
190
237
  for (const event of parsed.events) {
@@ -209,7 +256,7 @@ export function buildIR(parsed, chant, scale) {
209
256
  }
210
257
  currentPhrase.divisio = event;
211
258
  phrases.push(currentPhrase);
212
- currentPhrase = { syllables: [], beats: [], rhythmicType: null };
259
+ currentPhrase = { syllables: [], noteCount: 0, syllableCount: 0, beats: [], rhythmicType: null };
213
260
  }
214
261
  }
215
262
  if (currentLyric !== null && currentNotes.length > 0) {
@@ -0,0 +1,23 @@
1
+ import type { LyricRun } from "./types.js";
2
+ export interface DecodedLyric {
3
+ /** Clean display text — tags decoded, specials as real Unicode. */
4
+ text: string;
5
+ /** Styled spans covering `text`, present only when some style rides. */
6
+ runs?: LyricRun[];
7
+ /** Inside an open `<nlba>` — do not break a line before this syllable. */
8
+ keepWithPrev?: boolean;
9
+ }
10
+ /**
11
+ * One decoder per GABC source. Feed it each syllable's raw text in document
12
+ * order; open styles carry across calls.
13
+ */
14
+ export declare function createLyricDecoder(): {
15
+ decode(raw: string): DecodedLyric;
16
+ };
17
+ /**
18
+ * Trim a syllable's edge hyphens/whitespace from its runs, mirroring the
19
+ * emitters' display trim of the plain text, so the runs' concatenation stays
20
+ * equal to what is drawn.
21
+ */
22
+ export declare function trimRuns(runs: LyricRun[]): LyricRun[];
23
+ //# sourceMappingURL=lyric.d.ts.map
@@ -0,0 +1,234 @@
1
+ // Gregorio's <sp> shortcuts, as they occur in the corpus. The barred letters
2
+ // ℟/℣ have codepoints; the antiphon A takes the combining solidus. Unmapped
3
+ // content (‡, ¶, …) passes through literally — <sp> is "insert this symbol".
4
+ const SP_MAP = {
5
+ "R/": "℟", // ℟ response
6
+ "V/": "℣", // ℣ versicle
7
+ "A/": "A̸", // barred A (antiphon)
8
+ "*": "*", // the raised star (schola entry)
9
+ "+": "†", // † the flex cross
10
+ "ae": "æ", // æ
11
+ "'ae": "ǽ", // ǽ
12
+ "'æ": "ǽ", // 'æ → ǽ
13
+ "oe": "œ", // œ
14
+ "'oe": "œ́", // œ́
15
+ "oe'": "œ́", // corpus typo for the same
16
+ "'œ": "œ́",
17
+ };
18
+ // Editorial markers, each normalized to ONE style however the source wrote
19
+ // them (<i>, <c>, or plain text): the psalm incipit "Ps." rubric red, as the
20
+ // books print it; the iterum "ij."/"iij." (almost) always italic.
21
+ const PS_MARK = /^(\s*)(Ps\.)(?=\s|$)/;
22
+ const IJ_MARK = /(^|\s)(i{1,3}j\.)(?=\s|$)/;
23
+ /** Decode a <v> verbatim-TeX payload to display text (page refs vanish). */
24
+ function decodeTex(buf) {
25
+ const italic = /\\itshape\b/.test(buf);
26
+ const text = buf
27
+ .replace(/\\pageref\{[^}]*\}/g, "") // cross-refs to the paper book
28
+ .replace(/\\greheightstar\b/g, "*")
29
+ .replace(/\\ddag\b/g, "‡")
30
+ .replace(/\\P\b/g, "¶")
31
+ .replace(/\\textup\{([^}]*)\}/g, "$1")
32
+ .replace(/\\ /g, " ") // control space
33
+ .replace(/\\[a-zA-Z]+\*?/g, "") // any remaining command
34
+ .replace(/[{}]/g, "");
35
+ return { text, italic };
36
+ }
37
+ /** Plain lyric characters: centering braces drop, ~ is a TeX placeholder. */
38
+ function cleanText(s) {
39
+ return s.replace(/[{}~]/g, "");
40
+ }
41
+ /**
42
+ * One decoder per GABC source. Feed it each syllable's raw text in document
43
+ * order; open styles carry across calls.
44
+ */
45
+ export function createLyricDecoder() {
46
+ const st = {
47
+ italic: 0, bold: 0, smallCaps: 0, rubric: 0, alt: 0, nlba: 0, capture: null,
48
+ pendingSpace: false,
49
+ };
50
+ return {
51
+ decode(raw) {
52
+ const runs = [];
53
+ let styled = false;
54
+ const nlbaAtEntry = st.nlba;
55
+ let openedHere = false;
56
+ const push = (text, extraItalic = false) => {
57
+ if (!text || st.alt > 0)
58
+ return;
59
+ if (st.pendingSpace) {
60
+ // "℣Jubilate" → "℣ Jubilate"; "℣." keeps its period tight.
61
+ if (/^[A-Za-zÀ-ÿĀ-ž]/.test(text))
62
+ text = " " + text;
63
+ st.pendingSpace = false;
64
+ }
65
+ const run = { text };
66
+ if (st.italic > 0 || extraItalic)
67
+ run.italic = true;
68
+ if (st.bold > 0)
69
+ run.bold = true;
70
+ if (st.smallCaps > 0)
71
+ run.smallCaps = true;
72
+ if (st.rubric > 0)
73
+ run.rubric = true;
74
+ if (run.italic || run.bold || run.smallCaps || run.rubric)
75
+ styled = true;
76
+ const prev = runs[runs.length - 1];
77
+ if (prev &&
78
+ !prev.italic === !run.italic && !prev.bold === !run.bold &&
79
+ !prev.smallCaps === !run.smallCaps && !prev.rubric === !run.rubric) {
80
+ prev.text += text;
81
+ }
82
+ else {
83
+ runs.push(run);
84
+ }
85
+ };
86
+ for (const part of raw.split(/(<[^<>]*>)/)) {
87
+ if (!part)
88
+ continue;
89
+ if (part.startsWith("<") && part.endsWith(">")) {
90
+ const tag = part.slice(1, -1).toLowerCase();
91
+ switch (tag) {
92
+ case "i":
93
+ case "e":
94
+ st.italic++;
95
+ break; // elision renders italic
96
+ case "/i":
97
+ case "/e":
98
+ st.italic = Math.max(0, st.italic - 1);
99
+ break;
100
+ case "b":
101
+ st.bold++;
102
+ break;
103
+ case "/b":
104
+ st.bold = Math.max(0, st.bold - 1);
105
+ break;
106
+ case "sc":
107
+ st.smallCaps++;
108
+ break;
109
+ case "/sc":
110
+ st.smallCaps = Math.max(0, st.smallCaps - 1);
111
+ break;
112
+ case "c":
113
+ st.rubric++;
114
+ break;
115
+ case "/c":
116
+ st.rubric = Math.max(0, st.rubric - 1);
117
+ break;
118
+ case "alt":
119
+ st.alt++;
120
+ break;
121
+ // The one layout hint that survives decoding. <nlba> spans a group
122
+ // the editor will not let a line break inside — "T. P. Allelúia"
123
+ // and its verse, kept whole. It is a span like <i>, opening in one
124
+ // syllable and closing several later, so it counts depth here and
125
+ // is read per-syllable rather than resolved into the text.
126
+ case "nlba":
127
+ if (nlbaAtEntry === 0)
128
+ openedHere = true;
129
+ st.nlba++;
130
+ break;
131
+ case "/nlba":
132
+ st.nlba = Math.max(0, st.nlba - 1);
133
+ break;
134
+ case "/alt":
135
+ st.alt = Math.max(0, st.alt - 1);
136
+ break;
137
+ case "sp":
138
+ st.capture = { kind: "sp", buf: "" };
139
+ break;
140
+ case "/sp": {
141
+ const buf = (st.capture?.buf ?? "").trim();
142
+ st.capture = null;
143
+ if (buf) {
144
+ const mapped = SP_MAP[buf] ?? buf;
145
+ push(mapped);
146
+ if (mapped === "℣" || mapped === "℟")
147
+ st.pendingSpace = true;
148
+ }
149
+ break;
150
+ }
151
+ case "v":
152
+ st.capture = { kind: "v", buf: "" };
153
+ break;
154
+ case "/v": {
155
+ const tex = decodeTex(st.capture?.buf ?? "");
156
+ st.capture = null;
157
+ if (tex.text)
158
+ push(tex.text, tex.italic);
159
+ break;
160
+ }
161
+ // Structural / layout markup with no display form: the euouae
162
+ // marker (its text prints plain), no-line-break areas, above-lines
163
+ // clears — and anything unrecognized drops its brackets, keeps its
164
+ // content (the pre-decode behavior, minus the visible tag soup).
165
+ default: break;
166
+ }
167
+ }
168
+ else if (st.capture) {
169
+ st.capture.buf += part;
170
+ }
171
+ else {
172
+ push(cleanText(part));
173
+ }
174
+ }
175
+ const text = runs.map((r) => r.text).join("");
176
+ // Normalize the editorial markers: the marker's span takes EXACTLY its
177
+ // one style, clearing whatever markup the source happened to wrap it in.
178
+ let out = styled ? runs : null;
179
+ const sliceRuns = (from, a, b) => {
180
+ const acc = [];
181
+ let pos = 0;
182
+ for (const run of from) {
183
+ const s = Math.max(a, pos);
184
+ const e = Math.min(b, pos + run.text.length);
185
+ if (e > s)
186
+ acc.push({ ...run, text: run.text.slice(s - pos, e - pos) });
187
+ pos += run.text.length;
188
+ }
189
+ return acc;
190
+ };
191
+ const restyle = (start, end, style) => {
192
+ const base = out ?? [{ text }];
193
+ out = [
194
+ ...sliceRuns(base, 0, start),
195
+ { text: text.slice(start, end), ...style },
196
+ ...sliceRuns(base, end, text.length),
197
+ ];
198
+ };
199
+ const ps = PS_MARK.exec(text);
200
+ if (ps)
201
+ restyle(ps[1].length, ps[1].length + ps[2].length, { rubric: true });
202
+ const ij = IJ_MARK.exec(text);
203
+ if (ij) {
204
+ const start = ij.index + ij[1].length;
205
+ restyle(start, start + ij[2].length, { italic: true });
206
+ }
207
+ // Depth is read AFTER the syllable's own tags are applied. A group opens
208
+ // with `<nlba>` at the head of its first syllable, so that syllable is
209
+ // already inside the span and every later one is too — which is what the
210
+ // flag has to mean: no break BEFORE me. The first syllable of the group
211
+ // may still start a line; it is the seam inside the group that is sealed.
212
+ const keep = st.nlba > 0 && !openedHere;
213
+ const res = out ? { text, runs: out } : { text };
214
+ if (keep)
215
+ res.keepWithPrev = true;
216
+ return res;
217
+ },
218
+ };
219
+ }
220
+ /**
221
+ * Trim a syllable's edge hyphens/whitespace from its runs, mirroring the
222
+ * emitters' display trim of the plain text, so the runs' concatenation stays
223
+ * equal to what is drawn.
224
+ */
225
+ export function trimRuns(runs) {
226
+ const out = runs.map((r) => ({ ...r }));
227
+ if (out.length > 0)
228
+ out[0].text = out[0].text.replace(/^-+/, "").replace(/^\s+/, "");
229
+ const last = out[out.length - 1];
230
+ if (last)
231
+ last.text = last.text.replace(/-+$/, "").replace(/\s+$/, "");
232
+ return out.filter((r) => r.text.length > 0);
233
+ }
234
+ //# sourceMappingURL=lyric.js.map
@@ -1,4 +1,4 @@
1
- import type { ChantType, Score } from "./types.js";
1
+ import type { ChantType, ScoreIR } from "./types.js";
2
2
  export interface ChantMeta {
3
3
  mode: number | null;
4
4
  modeAlias: string | null;
@@ -15,5 +15,5 @@ export interface ChantMetaOptions {
15
15
  mode?: number;
16
16
  office?: ChantType;
17
17
  }
18
- export declare function computeMeta(ir: Score, options?: ChantMetaOptions): ChantMeta;
18
+ export declare function computeMeta(ir: ScoreIR, options?: ChantMetaOptions): ChantMeta;
19
19
  //# sourceMappingURL=meta.d.ts.map
@@ -8,6 +8,18 @@ export interface Modulation {
8
8
  toMode: number;
9
9
  /** 0–1: how strongly the foreign mode outscored the home mode, averaged. */
10
10
  confidence: number;
11
+ /**
12
+ * What the span is evidence of. "inflection" — a single phrase leaning away
13
+ * and back: passing colour, not a shift. "modulation" — a SUSTAINED internal
14
+ * excursion, two phrases or more, that returns. "transposition" — the whole
15
+ * chant sits in a foreign mode's frame
16
+ * (it does not close on its labeled final, and one foreign mode dominates
17
+ * most of its phrases), i.e. the melody is notated at a transposed position
18
+ * (the affinal) or the label disagrees with the notation. A transposed chant
19
+ * is not modulating: the displacement is global, and callers displaying
20
+ * "modulations" should treat these spans as a re-reading of the whole chant.
21
+ */
22
+ kind: "inflection" | "modulation" | "transposition";
11
23
  }
12
24
  /**
13
25
  * Detect tonal-centre shifts. For each phrase, score it against every mode; a