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,9 +1,23 @@
1
1
  import { computeModalAffinity } from "../temper/modality.js";
2
+ import { MODES } from "../temper/data/modes.js";
2
3
  // How much a foreign mode must outscore the home mode (in normalised affinity)
3
4
  // before a phrase counts as leaning away. Calibrated against Suñol's worked
4
5
  // examples [biblio: sunol-textbook]: at 0.25 the modulations he names in Christus
5
6
  // resurgens (to mode 3) register, while incidental colouring below that does not.
6
7
  const MARGIN = 0.25;
8
+ // A phrase leaning toward its own mode's authentic/plagal partner is NOT
9
+ // modulating: the partner shares the final, so the "lean" is AMBITUS — the
10
+ // phrase sitting high or low within the same maneria — not a tonal-centre
11
+ // shift. Measured over the corpus: partner leans were 18% of the lean-pairs behind
12
+ // the 28% of moded chants that read as wall-to-wall foreign. A category
13
+ // correction, not a threshold.
14
+ const maneriaPartner = (mode) => mode % 2 === 1 ? mode + 1 : mode - 1; // 1↔2, 3↔4, 5↔6, 7↔8
15
+ // A chant is read as TRANSPOSED (not modulating) when it does not close on its
16
+ // labeled mode's final AND a single foreign mode's spans cover most of its
17
+ // phrases. Corpus context: 81.6% of labeled chants close on their mode's
18
+ // final; of the remainder, the wall-to-wall foreign
19
+ // readings this rule reclassifies were 30% of ALL labeled chants before it.
20
+ const TRANSPOSITION_SHARE = 0.6;
7
21
  /** The pitch-class distribution of one phrase's notes (fractions summing to 1). */
8
22
  function phrasePcDistribution(phrase) {
9
23
  const counts = new Array(12).fill(0);
@@ -38,6 +52,8 @@ export function detectModulations(phrases, homeMode) {
38
52
  const top = affinity[0];
39
53
  if (!top || top.mode === homeMode)
40
54
  return null;
55
+ if (top.mode === maneriaPartner(homeMode))
56
+ return null;
41
57
  const home = affinity.find((a) => a.mode === homeMode);
42
58
  const margin = top.score - (home?.score ?? 0);
43
59
  return margin >= MARGIN ? { mode: top.mode, margin } : null;
@@ -54,6 +70,7 @@ export function detectModulations(phrases, homeMode) {
54
70
  endPhrase: run.start + run.margins.length - 1,
55
71
  toMode: run.mode,
56
72
  confidence: Math.min(1, Math.round(avg * 100) / 100),
73
+ kind: run.margins.length === 1 ? "inflection" : "modulation",
57
74
  });
58
75
  run = null;
59
76
  };
@@ -69,6 +86,38 @@ export function detectModulations(phrases, homeMode) {
69
86
  }
70
87
  }
71
88
  flush();
89
+ // Transposition, not modulation: the chant does not close on its labeled
90
+ // final, and one foreign mode's spans dominate its phrases — the displacement
91
+ // is global (affinal notation, or a label at odds with the notation).
92
+ const homeFinal = MODES.get(homeMode)?.final;
93
+ let closingPc;
94
+ for (let pi = phrases.length - 1; pi >= 0 && closingPc == null; pi--) {
95
+ for (let si = phrases[pi].syllables.length - 1; si >= 0; si--) {
96
+ const notes = phrases[pi].syllables[si].notes;
97
+ if (notes.length > 0) {
98
+ closingPc = notes[notes.length - 1].pitch.pc;
99
+ break;
100
+ }
101
+ }
102
+ }
103
+ if (homeFinal != null && closingPc != null && closingPc !== homeFinal % 12) {
104
+ const coverage = new Map();
105
+ for (const m of modulations) {
106
+ coverage.set(m.toMode, (coverage.get(m.toMode) ?? 0) + (m.endPhrase - m.startPhrase + 1));
107
+ }
108
+ // The share is over phrases that SOUND — a note-less phrase (an isolated
109
+ // divisio, an empty incise) can neither lean nor stay home, so counting it
110
+ // in the denominator quietly diluted every chant that carried one.
111
+ const sounding = phrases.filter((p) => !p.syllables.every((s) => s.notes.length === 0)).length;
112
+ for (const [toMode, phraseCount] of coverage) {
113
+ if (sounding > 0 && phraseCount / sounding >= TRANSPOSITION_SHARE) {
114
+ for (const m of modulations) {
115
+ if (m.toMode === toMode)
116
+ m.kind = "transposition";
117
+ }
118
+ }
119
+ }
120
+ }
72
121
  return modulations;
73
122
  }
74
123
  //# sourceMappingURL=modulation.js.map
@@ -16,11 +16,42 @@ export function classifyNeume(notes) {
16
16
  }
17
17
  const dirs = intervals.map(toDirection);
18
18
  let type = classifyShape(dirs);
19
- // Salicus: an ascending run whose ictus (GABC `'`) marks it apart from a plain
20
- // scandicus. The ictus sits on the second-to-last ascending note — the middle
21
- // note of a three-note salicus, the penultimate of a longer one (Suñol).
19
+ // Salicus: "a neume with at least three ascending notes in which the
20
+ // next-to-last is an oriscus" [biblio: cardine-semiology, ch. 16]. The
21
+ // ORISCUS is what makes it one — not the editorial ictus.
22
+ //
23
+ // RULED 2026-08-04 (Jeffrey): adopt Cardine's definition for DETECTION, not
24
+ // only for the prolongation. This is a second doctrine call beyond the
25
+ // original ruling and was ratified on its own terms, because it decides what
26
+ // the word `salicus` denotes everywhere in tonus — the neume type, the
27
+ // always-arsic override, the chironomia track, any caller reading
28
+ // `neume.type`.
29
+ //
30
+ // This detection was previously keyed on the Solesmes ictus (GABC `'`) on
31
+ // the second-to-last note, which is a different thing and a much larger set.
32
+ // Measured over the sung corpus today: 302 salici against 2,960 scandici, so
33
+ // the old rule claimed roughly 3,262 groups where 302 are real — and of that
34
+ // wider set only 9.4% carry an oriscus at all. An ictus-marked ascent with no
35
+ // oriscus IS a scandicus that Solesmes marked for rhythm; the mark survives
36
+ // on `context.ictus` for anyone reading it. Bevenot calls the conflation "a
37
+ // trap" [biblio: cardine-semiology].
38
+ //
39
+ // (This paragraph read 2,795 / 36 / 226 until 2026-08-11. Those came from
40
+ // working/qa-sweep/salicus-before.json, a run over 2,887 chants and 28,498
41
+ // phrases — a corpus that predates the accidental fix in `parse.ts`, which
42
+ // was emitting a phantom note at every accidental and inflating every count
43
+ // downstream. The shape of the finding survived re-measurement; the figures
44
+ // did not.)
45
+ //
46
+ // The LIMIT of this, stated because it is easy to mistake for a bug: tonus
47
+ // sees only what the transcription marks. Bevenot's own example — the mode-6
48
+ // Requiem introit's fa-sol-la — carries the Solesmes ictus and NO oriscus in
49
+ // GregoBase (gregobase:766, on "ae" and "do"), so it reads here as a
50
+ // scandicus. He is reading the manuscripts; we are reading a printed edition
51
+ // that resolved the oriscus away. Recovering those needs the MSS, not a rule
52
+ // change: a guess dressed as a measurement is worse than the gap.
22
53
  const allAscending = dirs.length >= 2 && dirs.every((d) => d === "up");
23
- if (allAscending && notes[notes.length - 2].context.ictus) {
54
+ if (allAscending && notes[notes.length - 2].context.oriscus) {
24
55
  type = "salicus";
25
56
  }
26
57
  return { type, intervals, hasQuilisma, hasLiquescent, hasStrophicus };
@@ -1,4 +1,5 @@
1
1
  import { buildArticulation } from "./articulation.js";
2
+ import { createLyricDecoder } from "./lyric.js";
2
3
  import { detectVowelAccent } from "../chant/syllabify.js";
3
4
  // Constants
4
5
  const DEFAULT_OPTIONS = {
@@ -45,18 +46,21 @@ function initialAccidentalState(clef) {
45
46
  state.set(6, -1); // B-flat key signature
46
47
  return state;
47
48
  }
49
+ function isLineBreak(token) {
50
+ // GABC's explicit break: `z` a line break, `Z` a page break, `z0` a break
51
+ // that suppresses the custos. tonus paginates nothing, so all three mean the
52
+ // same thing here — end the system.
53
+ return token === "z" || token === "Z" || token === "z0";
54
+ }
48
55
  function isSkippable(token) {
49
56
  return (token === " " ||
50
- token === "z" ||
51
- token === "Z" ||
52
- token === "z0" ||
53
57
  token === "\r" ||
54
58
  token.startsWith("{") ||
55
59
  token.includes("+"));
56
60
  }
57
61
  // parseNeume
58
62
  function parseNeume(notation, context) {
59
- const { lyric, clef, oct, syllableIndex, accent, accidentalState, profile } = context;
63
+ const { lyric, runs, keepWithPrev, clef, oct, syllableIndex, accent, accidentalState, pending, profile } = context;
60
64
  const weights = profile.weights;
61
65
  const ruleGain = profile.ruleGain ?? 1.0;
62
66
  const contourScale = profile.contourScale ?? 0.2;
@@ -96,12 +100,15 @@ function parseNeume(notation, context) {
96
100
  w += weights.dashWeight; // negative weight
97
101
  durWeight += weights.dashDuration;
98
102
  }
99
- const letter = token[0]?.toLowerCase();
103
+ const rawLetter = token[0];
104
+ const letter = rawLetter?.toLowerCase();
100
105
  if (!letter)
101
106
  return;
102
107
  const pitchOffset = letter.charCodeAt(0) - "a".charCodeAt(0);
103
108
  if (pitchOffset < 0 || pitchOffset > 12)
104
109
  return;
110
+ // An uppercase pitch letter is a punctum inclinatum (the small diamond).
111
+ const isInclinatum = rawLetter !== letter;
105
112
  const clefOffset = CLEF_OFFSETS.get(clef) ?? 0;
106
113
  const pos = pitchOffset - 6 - clefOffset;
107
114
  const octave = Math.floor(pos / 7) + oct + 1;
@@ -125,6 +132,29 @@ function parseNeume(notation, context) {
125
132
  explicitAccidental = 1;
126
133
  accidentalState.set(degree, 1);
127
134
  }
135
+ // An accidental is a MARK, not a note. `fx` says "F is flat from here" and
136
+ // is drawn as a flat sign on the F line — nothing is sung at that moment.
137
+ // The state above is its whole effect, so the token ends here.
138
+ //
139
+ // It used to fall through and emit a note at the flattened pitch, which is
140
+ // why `A(fxfg)` returned three notes for two and duplicated the pitch: the
141
+ // marker sounded, then the F it governs sounded again. 1337 markers across
142
+ // 426 Graduale chants — 0.97% of every note in the book was a phantom, with
143
+ // a wrong pitch, inflating counts and inventing an interval of a unison
144
+ // before each one.
145
+ //
146
+ // The token is the mark alone: a bare pitch letter plus x/y/#. A letter
147
+ // carrying an accidental AND other modifiers is still a real note (nothing
148
+ // in the corpus writes one, but the reading is the safe one).
149
+ if (explicitAccidental !== null && /^[a-mA-M][xy#]$/.test(token)) {
150
+ // The SIGN still belongs on the page — a flat is drawn before the note it
151
+ // governs, which is where the books put it. The mark had been riding the
152
+ // phantom note itself; with that gone it is handed to the next real note,
153
+ // which is both correct notation and what the emitters already expect
154
+ // (they key the glyph off `accidentalSource === "explicit"`).
155
+ pending.acc = explicitAccidental;
156
+ return;
157
+ }
128
158
  // Apply bmolle to B (degree 6 = B natural step 11 → B-flat step 10)
129
159
  let step = baseStep;
130
160
  if (bmolle && degree === 6)
@@ -137,13 +167,28 @@ function parseNeume(notation, context) {
137
167
  step += activeAccidental;
138
168
  }
139
169
  step += octave * 12;
140
- const accidentalSource = explicitAccidental !== null
170
+ // A sign handed over by a preceding accidental token is drawn AT this note,
171
+ // whatever its degree. A GABC accidental is a key-signature mark, not an
172
+ // alteration of the note beside it: `gx` says "G is flat for the rest of the
173
+ // word" and the books print the flat where it is written, then sing on.
174
+ // Measured across 693 corpus markers, only 11% are immediately followed by
175
+ // the same letter — so requiring the degrees to agree (my first attempt)
176
+ // suppressed the sign in nine cases out of ten.
177
+ const inheritedSign = pending.acc;
178
+ if (inheritedSign !== null)
179
+ pending.acc = null;
180
+ const inherited = inheritedSign !== null;
181
+ const accidentalSource = explicitAccidental !== null || inherited
141
182
  ? "explicit"
142
183
  : accidentalState.has(degree)
143
184
  ? "state"
144
185
  : "none";
145
- // Ictus markers (' and _)
146
- if (modifiers.includes("'") || modifiers.includes("_")) {
186
+ // Ictus markers (' and _). Both signal the rhythmic footfall (weight
187
+ // unchanged), but they are DISTINCT written marks the renderer must draw
188
+ // apart: ' is the vertical episema (ictus mark), _ the horizontal episema.
189
+ const hasIctusSign = modifiers.includes("'");
190
+ const hasEpisema = modifiers.includes("_");
191
+ if (hasIctusSign || hasEpisema) {
147
192
  w += weights.ictusWeight;
148
193
  durWeight += weights.ictusDuration;
149
194
  ictus = true;
@@ -228,14 +273,43 @@ function parseNeume(notation, context) {
228
273
  prev._weight += weights.repercussionOriscusWeight;
229
274
  }
230
275
  }
276
+ // Written note shape (gregorio's S_* vocabulary, simplified) — what the
277
+ // renderer draws. Priority: the inclinatum diamond and the ornamental
278
+ // shapes win over the plain punctum/virga forms.
279
+ let shape = "punctum";
280
+ if (isInclinatum)
281
+ shape = "inclinatum";
282
+ else if (isQuilisma)
283
+ shape = "quilisma";
284
+ else if (isStrophicus)
285
+ shape = "strophicus";
286
+ else if (modifiers.includes("V"))
287
+ shape = "virgaReversa";
288
+ else if (modifiers.includes("v"))
289
+ shape = "virga";
290
+ else if (isOriscus)
291
+ shape = "oriscus";
292
+ else if (modifiers.includes("r"))
293
+ shape = "cavum";
294
+ else if (modifiers.includes("="))
295
+ shape = "linea";
231
296
  intermed.push({
232
297
  step,
233
298
  degree,
234
299
  lyric,
300
+ runs,
301
+ keepWithPrev,
235
302
  syllableIndex,
236
303
  neumeGroup,
304
+ staffLetter: letter,
305
+ clef,
306
+ shape,
237
307
  ictus,
308
+ ictusSign: hasIctusSign,
309
+ episema: hasEpisema,
238
310
  accidental: activeAccidental,
311
+ ...(inheritedSign !== null ? { accidentalSign: inheritedSign } : {}),
312
+ ...(explicitAccidental !== null ? { accidentalSign: explicitAccidental } : {}),
239
313
  accidentalSource,
240
314
  quilisma: isQuilisma,
241
315
  liquescent: isLiquescent,
@@ -285,12 +359,24 @@ function parseNeume(notation, context) {
285
359
  type: "note",
286
360
  step: note.step,
287
361
  lyric: note.lyric,
362
+ runs: note.runs,
363
+ keepWithPrev: note.keepWithPrev,
288
364
  syllableIndex: note.syllableIndex,
289
365
  neumeGroup: note.neumeGroup,
366
+ // Tonic word-accent of this note's syllable (Latin accentuation), the same
367
+ // boolean the articulation weighting reads above — surfaced as data so the
368
+ // tabula carries it (accent stats, prose-cursus). Syllable-wide constant.
369
+ accent,
370
+ staffLetter: note.staffLetter,
371
+ clef: note.clef,
372
+ shape: note.shape,
290
373
  ictus: note.ictus,
374
+ ictusSign: note.ictusSign,
375
+ episema: note.episema,
291
376
  weight,
292
377
  duration,
293
378
  accidental: note.accidental,
379
+ ...(note.accidentalSign !== undefined ? { accidentalSign: note.accidentalSign } : {}),
294
380
  accidentalSource: note.accidentalSource,
295
381
  quilisma: note.quilisma,
296
382
  liquescent: note.liquescent,
@@ -316,16 +402,38 @@ export function parseGABC(gabc, options = {}) {
316
402
  };
317
403
  }
318
404
  let currentClef = "c3";
405
+ // A `z` sets this, and the next note born carries it. The marker usually sits
406
+ // in its own group — "…son.(c.) (z) Chri(h)ste…" — so the flag has to outlive
407
+ // the syllable it appeared in rather than being consumed where it was seen.
408
+ let pendingLineBreak = false;
319
409
  let accidentalState = initialAccidentalState(currentClef);
410
+ // An accidental token draws a sign but sounds nothing, so the sign is claimed
411
+ // by the next real note. It must survive the syllable boundary: the marker
412
+ // very often sits ALONE in its own group — "a(gx)b(a)" — so a flag local to
413
+ // one neume would be discarded before the note it governs is parsed.
414
+ const pendingAccidental = { acc: null };
415
+ // One lyric decoder per source: GABC style tags open and close across
416
+ // syllable (and word) boundaries, so the decode state rides the whole walk.
417
+ const lyricDecoder = createLyricDecoder();
320
418
  const split = source.replace(/\)\s(?=[^\)]*(?:\(|$))/g, ")\n").split(/\n/g);
321
419
  split.forEach((word) => {
322
420
  if (!word)
323
421
  return;
422
+ // A flat holds for the rest of the WORD or until a divisio — a new word
423
+ // clears any accidental the previous word set (the clef's own signature
424
+ // persists). This is the scope the header states, now enforced.
425
+ accidentalState = initialAccidentalState(currentClef);
426
+ pendingAccidental.acc = null; // a new clef clears any unclaimed sign
324
427
  SYLLABLES_REGEX.lastIndex = 0;
325
428
  let match;
326
429
  let syllableIndex = 0;
327
430
  while ((match = SYLLABLES_REGEX.exec(word)) !== null) {
328
- const text = (match[1] ? match[1].trim().split("|")[0] : "") || "";
431
+ const rawText = (match[1] ? match[1].trim().split("|")[0] : "") || "";
432
+ // Decode GABC lyric markup EVERY syllable — a style opened here may
433
+ // close syllables later, so the decoder's state must advance even when
434
+ // this syllable carries no notes.
435
+ const decoded = lyricDecoder.decode(rawText);
436
+ const text = decoded.text;
329
437
  const notation = match[2] ? match[2].match(NOTATIONS_REGEX) : null;
330
438
  if (!notation || notation.length === 0) {
331
439
  syllableIndex += 1;
@@ -338,6 +446,7 @@ export function parseGABC(gabc, options = {}) {
338
446
  if (CLEF_OFFSETS.has(token)) {
339
447
  currentClef = token;
340
448
  accidentalState = initialAccidentalState(currentClef);
449
+ pendingAccidental.acc = null; // a new clef clears any unclaimed sign
341
450
  continue;
342
451
  }
343
452
  if (DIVISIO_DURATIONS.has(token)) {
@@ -345,21 +454,52 @@ export function parseGABC(gabc, options = {}) {
345
454
  divisioToken = token;
346
455
  continue;
347
456
  }
457
+ // A BREAK IS ITS OWN GROUP. GABC writes one as `(z)`, alone; a `z`
458
+ // that FOLLOWS notes inside a group is not one. The tokenizer splits
459
+ // `ra(gz)ti` into ["g","z"] because `z` is not in a note's suffix
460
+ // class, and that stray token was read as the engraver's break — so
461
+ // three responsories in the Nocturnale cut their systems a third of
462
+ // the way across the page and left the rest of the line empty.
463
+ //
464
+ // The rule is NOTES BEFORE IT, not "alone in its group": `(z0::c3)` is
465
+ // a real break carrying the double bar and the new clef after it, and
466
+ // those are common. What is never a break is a `z` that FOLLOWS
467
+ // notes, which is what `(gz)` and `(hjHG__z)` are.
468
+ if (isLineBreak(token)) {
469
+ if (noteTokens.length === 0)
470
+ pendingLineBreak = true;
471
+ continue;
472
+ }
348
473
  if (isSkippable(token))
349
474
  continue;
350
475
  noteTokens.push(token);
351
476
  }
352
477
  if (noteTokens.length > 0) {
478
+ const before = events.length;
353
479
  const accent = opts.useVowelAccent ? detectVowelAccent(text) : false;
354
480
  events.push(...parseNeume(noteTokens, {
355
481
  lyric: text,
482
+ runs: decoded.runs,
483
+ keepWithPrev: decoded.keepWithPrev,
356
484
  clef: currentClef,
357
485
  oct: opts.oct,
358
486
  syllableIndex,
359
487
  accent,
360
488
  accidentalState,
489
+ pending: pendingAccidental,
361
490
  profile: articulation,
362
491
  }));
492
+ // The break lands on the FIRST note the marker precedes, and is
493
+ // spent there. A `z` usually sits in a group with NO notes of its
494
+ // own — "…(::) (z)Ps. E(g)ru…" — so the flag outlives that group
495
+ // and is claimed by the next note born, which is the one the
496
+ // engraver meant to start the new line.
497
+ if (pendingLineBreak) {
498
+ const first = events[before];
499
+ if (first && first.type === "note")
500
+ first.lineBreak = true;
501
+ pendingLineBreak = false;
502
+ }
363
503
  }
364
504
  if (divisioToken) {
365
505
  events.push({
@@ -368,6 +508,7 @@ export function parseGABC(gabc, options = {}) {
368
508
  duration: DIVISIO_DURATIONS.get(divisioToken) ?? 0.5,
369
509
  });
370
510
  accidentalState = initialAccidentalState(currentClef);
511
+ pendingAccidental.acc = null; // a new clef clears any unclaimed sign
371
512
  }
372
513
  syllableIndex += 1;
373
514
  }
@@ -161,9 +161,10 @@ const TENOR_GAIN = 0.05;
161
161
  const TENOR_DISTANCE_DIVISOR = 6;
162
162
  const CADENCE_VELOCITY_FACTOR = 0.5;
163
163
  const CADENCE_DURATION_FACTOR = 0.6;
164
- // The phrasing-side reading of the divisio (bar-line) hierarchy. This is the same
165
- // bar hierarchy tabulated canonically in docs/score.md and weighted for analysis
166
- // by prosody.ts's cadenceWeight ladder — but here the weights differ on purpose:
164
+ // The phrasing-side reading of the divisio (bar-line) hierarchy. This is the
165
+ // same bar hierarchy tabulated canonically in ../../../docs/api/score.md and
166
+ // weighted for analysis by prosody.ts's cadenceWeight ladder — but here the
167
+ // weights differ on purpose:
167
168
  // this is a *shaping* factor, so the virgula (`) gets 0 (a breath, no cadential
168
169
  // stress), where the analytic ladder still counts it. Do not unify the three.
169
170
  const DIVISIO_STRENGTH = {
@@ -18,6 +18,36 @@ export interface RhythmicProfile {
18
18
  avgGroupSize: number;
19
19
  maxGroupSize: number;
20
20
  }
21
+ /** Melodic-motion statistics over adjacent notes (within a phrase). */
22
+ export interface IntervalStats {
23
+ /** Signed semitone interval → count. Movement across a divisio is excluded. */
24
+ histogram: Record<number, number>;
25
+ /** Largest absolute interval, in semitones. */
26
+ maxLeap: number;
27
+ /** Fraction of motions that are leaps (≥ a fourth, 5+ semitones). */
28
+ leapRate: number;
29
+ /** Motion by class: step (1–2 st), skip (a third, 3–4), leap (a fourth+, 5+). */
30
+ motus: {
31
+ step: number;
32
+ skip: number;
33
+ leap: number;
34
+ };
35
+ }
36
+ /** The melodic arch of the piece: where it begins, peaks, and ends, and its shape. */
37
+ export interface Arcus {
38
+ /** First note's MIDI. */
39
+ initial: number;
40
+ /** Highest note's MIDI. */
41
+ peak: number;
42
+ /** Last note's MIDI. */
43
+ final: number;
44
+ /**
45
+ * Signed arch index: how much the melody rises to its peak versus where it
46
+ * settles. +1 a full arch (rises high, returns low), 0 flat/monotonic,
47
+ * negative when it ends above where it began relative to the climb.
48
+ */
49
+ archIndex: number;
50
+ }
21
51
  export interface Prosody {
22
52
  noteCount: number;
23
53
  syllableCount: number;
@@ -26,6 +56,14 @@ export interface Prosody {
26
56
  ambitus: number | null;
27
57
  melismaRatio: number;
28
58
  melismaByPhrase: number[];
59
+ /** Mean notes-per-syllable of each phrase's FINAL syllable — the cadential melisma. */
60
+ melismaCadential: number;
61
+ /** Mean pitch minus the final note, in semitones — how high the melody sits above its rest. */
62
+ tessitura: number | null;
63
+ /** Melodic-interval statistics over adjacent within-phrase notes. */
64
+ intervals: IntervalStats;
65
+ /** The melodic arch: initial/peak/final and a signed arch index. */
66
+ arcus: Arcus | null;
29
67
  ictusRate: number;
30
68
  rhythmicProfile: RhythmicProfile;
31
69
  cadenceWeight: number;
@@ -16,6 +16,18 @@ export function computeProsody(phrases) {
16
16
  };
17
17
  let cadenceWeight = 0;
18
18
  const melismaByPhrase = [];
19
+ // Interval statistics — adjacent notes WITHIN a phrase; prevMidi resets to null
20
+ // at each phrase so a breath across a divisio is not counted as a leap.
21
+ const histogram = {};
22
+ let motions = 0, stepCount = 0, skipCount = 0, leapCount = 0, maxLeap = 0;
23
+ let prevMidi = null;
24
+ // Tessitura + arch: running MIDI sum, the first note, the peak, the last note.
25
+ let midiSum = 0;
26
+ let firstMidi = null;
27
+ let lastMidi = 0;
28
+ let peakMidi = -Infinity;
29
+ // Cadential melisma — the note count of each phrase's final sung syllable.
30
+ const cadentialMelismas = [];
19
31
  const closeGroup = () => {
20
32
  if (currentGroupSize > 0) {
21
33
  groupSizes.push(currentGroupSize);
@@ -27,24 +39,51 @@ export function computeProsody(phrases) {
27
39
  phraseCount++;
28
40
  let phraseNotes = 0;
29
41
  let phraseSyllables = 0;
42
+ let lastSylNotes = 0; // notes on this phrase's most recent sung syllable
43
+ prevMidi = null; // intervals never cross a phrase boundary (a breath)
30
44
  for (const syl of phrase.syllables) {
31
45
  if (syl.notes.length === 0)
32
46
  continue;
33
47
  phraseSyllables++;
34
48
  syllableCount++;
49
+ lastSylNotes = syl.notes.length;
35
50
  for (const note of syl.notes) {
51
+ const midi = note.pitch.midi;
36
52
  noteCount++;
37
53
  phraseNotes++;
38
- if (note.pitch.midi < minMidi)
39
- minMidi = note.pitch.midi;
40
- if (note.pitch.midi > maxMidi)
41
- maxMidi = note.pitch.midi;
54
+ if (midi < minMidi)
55
+ minMidi = midi;
56
+ if (midi > maxMidi)
57
+ maxMidi = midi;
42
58
  if (note.context.ictus)
43
59
  ictusCount++;
44
60
  if (note.performance.rhythmicShape === "arsic")
45
61
  arsicCount++;
46
62
  else
47
63
  theticCount++;
64
+ // Tessitura + arch running values.
65
+ midiSum += midi;
66
+ if (firstMidi === null)
67
+ firstMidi = midi;
68
+ if (midi > peakMidi)
69
+ peakMidi = midi;
70
+ lastMidi = midi;
71
+ // Adjacent-note interval, within the phrase only.
72
+ if (prevMidi !== null) {
73
+ const iv = midi - prevMidi;
74
+ histogram[iv] = (histogram[iv] ?? 0) + 1;
75
+ const abs = Math.abs(iv);
76
+ motions++;
77
+ if (abs > maxLeap)
78
+ maxLeap = abs;
79
+ if (abs <= 2)
80
+ stepCount++;
81
+ else if (abs <= 4)
82
+ skipCount++;
83
+ else
84
+ leapCount++;
85
+ }
86
+ prevMidi = midi;
48
87
  if (note.performance.rhythmicIndex === 1) {
49
88
  closeGroup();
50
89
  currentGroupSize = 1;
@@ -54,10 +93,12 @@ export function computeProsody(phrases) {
54
93
  }
55
94
  }
56
95
  }
96
+ if (phraseSyllables > 0)
97
+ cadentialMelismas.push(lastSylNotes);
57
98
  if (phrase.divisio) {
58
99
  // Analytic bar-importance weights, one rung per divisio in the bar-line
59
- // hierarchy (the canonical table is in docs/score.md). These are a
60
- // MEASUREMENT — every divisio counts, including the virgula (`) — distinct
100
+ // hierarchy (the canonical table is in ../../../docs/api/score.md).
101
+ // These are a MEASUREMENT — every divisio counts, including the virgula (`) — distinct
61
102
  // from phrasing.ts's DIVISIO_STRENGTH, which zeroes the virgula because it
62
103
  // is a shaping factor, not a count.
63
104
  const d = phrase.divisio.divisio;
@@ -92,6 +133,25 @@ export function computeProsody(phrases) {
92
133
  const avgGroupSize = groupSizes.length > 0
93
134
  ? groupSizes.reduce((s, n) => s + n, 0) / groupSizes.length
94
135
  : 0;
136
+ // Tessitura: mean height above the resting (final) note, in semitones.
137
+ const tessitura = noteCount > 0 ? midiSum / noteCount - lastMidi : null;
138
+ // Arch: the climb to the peak vs. the descent to the close, normalized by the
139
+ // total climb so a full rise-and-return reads ~+1 and a monotone rise reads ~0.
140
+ let arcus = null;
141
+ if (firstMidi !== null) {
142
+ const climb = peakMidi - firstMidi;
143
+ const archIndex = climb > 0 ? (peakMidi - lastMidi) / climb : 0;
144
+ arcus = { initial: firstMidi, peak: peakMidi, final: lastMidi, archIndex };
145
+ }
146
+ const intervals = {
147
+ histogram,
148
+ maxLeap,
149
+ leapRate: motions > 0 ? leapCount / motions : 0,
150
+ motus: { step: stepCount, skip: skipCount, leap: leapCount },
151
+ };
152
+ const melismaCadential = cadentialMelismas.length > 0
153
+ ? cadentialMelismas.reduce((s, n) => s + n, 0) / cadentialMelismas.length
154
+ : 0;
95
155
  return {
96
156
  phraseCount,
97
157
  noteCount,
@@ -100,6 +160,10 @@ export function computeProsody(phrases) {
100
160
  ambitus: noteRange?.span ?? null,
101
161
  melismaRatio: syllableCount > 0 ? noteCount / syllableCount : 0,
102
162
  melismaByPhrase,
163
+ melismaCadential,
164
+ tessitura,
165
+ intervals,
166
+ arcus,
103
167
  ictusRate: noteCount > 0 ? ictusCount / noteCount : 0,
104
168
  rhythmicProfile: {
105
169
  arsic: arsicCount,