tonus 0.1.8 → 0.6.1

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 (166) hide show
  1. package/BIBLIOGRAPHY.md +138 -108
  2. package/CHANGELOG.md +664 -1
  3. package/LICENSE +133 -29
  4. package/README.md +107 -80
  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/data/zodiac.d.ts +47 -0
  83. package/dist/engines/harmonia/data/zodiac.js +163 -0
  84. package/dist/engines/harmonia/tabula.d.ts +3 -0
  85. package/dist/engines/harmonia/tabula.js +1 -0
  86. package/dist/engines/harmonia/voice.d.ts +4 -0
  87. package/dist/engines/harmonia/voice.js +8 -4
  88. package/dist/engines/imprint.js +14 -1
  89. package/dist/engines/planet/orbital.js +4 -4
  90. package/dist/engines/planet/planet.d.ts +10 -0
  91. package/dist/engines/planet/planet.js +30 -3
  92. package/dist/engines/planet/position.js +13 -10
  93. package/dist/engines/planet/types.d.ts +1 -0
  94. package/dist/engines/score/api.d.ts +2 -13
  95. package/dist/engines/score/api.js +21 -8
  96. package/dist/engines/score/articulation.js +2 -2
  97. package/dist/engines/score/cadence.d.ts +76 -0
  98. package/dist/engines/score/cadence.js +96 -0
  99. package/dist/engines/score/emitters/accidentals.d.ts +21 -0
  100. package/dist/engines/score/emitters/accidentals.js +88 -0
  101. package/dist/engines/score/emitters/atramentum.d.ts +107 -0
  102. package/dist/engines/score/emitters/atramentum.js +239 -0
  103. package/dist/engines/score/emitters/breaking.d.ts +62 -0
  104. package/dist/engines/score/emitters/breaking.js +80 -0
  105. package/dist/engines/score/emitters/moderna.d.ts +38 -0
  106. package/dist/engines/score/emitters/moderna.js +612 -0
  107. package/dist/engines/score/emitters/svg.d.ts +143 -0
  108. package/dist/engines/score/emitters/svg.js +1335 -0
  109. package/dist/engines/score/emitters/tracks.d.ts +104 -0
  110. package/dist/engines/score/emitters/tracks.js +728 -0
  111. package/dist/engines/score/infer.d.ts +3 -3
  112. package/dist/engines/score/infer.js +2 -2
  113. package/dist/engines/score/inscriptio.d.ts +69 -0
  114. package/dist/engines/score/inscriptio.js +138 -0
  115. package/dist/engines/score/ir.d.ts +2 -2
  116. package/dist/engines/score/ir.js +59 -12
  117. package/dist/engines/score/lyric.d.ts +23 -0
  118. package/dist/engines/score/lyric.js +234 -0
  119. package/dist/engines/score/meta.d.ts +2 -2
  120. package/dist/engines/score/modulation.d.ts +12 -0
  121. package/dist/engines/score/modulation.js +49 -0
  122. package/dist/engines/score/neume.js +35 -4
  123. package/dist/engines/score/parse.js +150 -9
  124. package/dist/engines/score/phrasing.js +4 -3
  125. package/dist/engines/score/prosody.d.ts +38 -0
  126. package/dist/engines/score/prosody.js +70 -6
  127. package/dist/engines/score/tabula.d.ts +37 -5
  128. package/dist/engines/score/tabula.js +18 -0
  129. package/dist/engines/score/types.d.ts +88 -1
  130. package/dist/engines/temper/api.d.ts +4 -1
  131. package/dist/engines/temper/api.js +28 -5
  132. package/dist/engines/temper/data/guido.js +6 -2
  133. package/dist/engines/temper/data/modes.d.ts +6 -0
  134. package/dist/engines/temper/data/modes.js +42 -0
  135. package/dist/engines/temper/data/tones.d.ts +1 -1
  136. package/dist/engines/temper/data/tones.js +20 -11
  137. package/dist/engines/temper/interval.js +4 -3
  138. package/dist/engines/temper/modality.d.ts +11 -2
  139. package/dist/engines/temper/modality.js +74 -2
  140. package/dist/engines/temper/modes.d.ts +1 -1
  141. package/dist/engines/temper/pitch.d.ts +1 -1
  142. package/dist/engines/temper/pitch.js +12 -2
  143. package/dist/engines/temper/scale.d.ts +53 -0
  144. package/dist/engines/temper/scale.js +107 -8
  145. package/dist/index.d.ts +26 -8
  146. package/dist/index.js +40 -4
  147. package/docs/api/calendar.md +279 -0
  148. package/docs/api/census.md +288 -0
  149. package/docs/api/chant.md +657 -0
  150. package/docs/api/heavens.md +396 -0
  151. package/docs/api/index.md +265 -0
  152. package/docs/api/score.md +906 -0
  153. package/docs/api/tuning.md +619 -0
  154. package/package.json +13 -5
  155. package/dist/data/office-matins-roman.d.ts +0 -19
  156. package/dist/data/office-matins-roman.js +0 -4383
  157. package/dist/data/office-psalms-roman.d.ts +0 -15
  158. package/dist/data/office-psalms-roman.js +0 -28
  159. package/dist/data/office-roman.d.ts +0 -19
  160. package/dist/data/office-roman.js +0 -13792
  161. package/dist/engines/chant/matutinum.d.ts +0 -33
  162. package/dist/engines/chant/matutinum.js +0 -81
  163. package/dist/engines/score/emitters/midi.d.ts +0 -65
  164. package/dist/engines/score/emitters/midi.js +0 -162
  165. package/dist/engines/score/emitters/musicxml.d.ts +0 -18
  166. package/dist/engines/score/emitters/musicxml.js +0 -166
@@ -125,6 +125,84 @@ function bestFigure(observed, figures) {
125
125
  }
126
126
  return best;
127
127
  }
128
+ // ── The corpus catalogue (CADENTIAE) ────────────────────────────────────────
129
+ // The mining keyed tails by their last <=4 notes; the classifier reads the same
130
+ // span out of the (longer) formula window.
131
+ const TAIL = 4;
132
+ /**
133
+ * Octave-reduce a semitone offset to [-5..+6].
134
+ *
135
+ * NOT part of the family key — kept because the folded value is still worth
136
+ * reporting (it is the scale DEGREE, mode-theoretically real). As a key the
137
+ * fold made a fifth ABOVE the final share a family with a fourth BELOW:
138
+ * measured over 27,985 phrase ends, 3,499 landed on @-5, of which 2,427 were
139
+ * really +7 and 1,072 really -5. Two opposite gestures, one key. Arrival in
140
+ * the key is therefore the SIGNED offset; see cadenceKeys().
141
+ */
142
+ export function reduceArrival(semitones) {
143
+ let a = semitones % 12;
144
+ if (a > 6)
145
+ a -= 12;
146
+ if (a < -5)
147
+ a += 12;
148
+ return a;
149
+ }
150
+ /**
151
+ * THE cadence family key — one implementation, shared by every consumer.
152
+ *
153
+ * This once existed three times: here (keyed off the Phrase tree), in
154
+ * tonus-corpus `census/_shared.mjs` (keyed off flat tabula rows), and in the
155
+ * CADENTIAE miner (a character-for-character copy of the census one). They
156
+ * agreed — measured corpus-wide, 28,051 engine cadences against 27,985 census
157
+ * phrase ends with ZERO key disagreements — but agreement by luck across three
158
+ * copies is what "no second parser, no drift" forbids; hence this one shared
159
+ * function.
160
+ *
161
+ * Takes the FLAT shape, because that is what the census and the miner have; the
162
+ * engine's own detection flattens into it. A phrase end is a `phraseIndex`
163
+ * transition or the last row.
164
+ *
165
+ * A ONE-NOTE PHRASE IS A CADENCE — it has a landing but no gesture, so it is
166
+ * emitted with an empty shape rather than skipped. (The census once dropped
167
+ * these — 68 corpus-wide, all real phrases carrying a real divisio, mostly
168
+ * "::" at the chant end — a hole this shared key closes.)
169
+ */
170
+ export function cadenceKeys(rows, finalMidi) {
171
+ if (!rows.length)
172
+ return [];
173
+ const final = finalMidi ?? rows[rows.length - 1].midi;
174
+ const events = [];
175
+ for (let i = 0; i < rows.length; i++) {
176
+ const next = rows[i + 1];
177
+ if (next && next.phraseIndex === rows[i].phraseIndex)
178
+ continue; // not a phrase end
179
+ // The last <=TAIL rows of this phrase, ending at row i.
180
+ const seg = [];
181
+ for (let j = i; j >= 0 && seg.length < TAIL && rows[j].phraseIndex === rows[i].phraseIndex; j--) {
182
+ seg.unshift(rows[j]);
183
+ }
184
+ const shape = seg.slice(1).map((r, k) => r.midi - seg[k].midi);
185
+ const arrival = seg[seg.length - 1].midi - final;
186
+ events.push({
187
+ key: `${shape.join(",")} @${arrival}`,
188
+ shape,
189
+ arrival,
190
+ degree: reduceArrival(arrival),
191
+ isFinal: rows[i].divisio === "::" || !next,
192
+ phraseIndex: rows[i].phraseIndex,
193
+ });
194
+ }
195
+ return events;
196
+ }
197
+ /** The chant's closing note — the reference every arrival is measured from. */
198
+ function chantFinalMidi(phrases) {
199
+ for (let pi = phrases.length - 1; pi >= 0; pi--) {
200
+ const w = phraseFinalWindow(phrases[pi]);
201
+ if (w.length > 0)
202
+ return w[w.length - 1].midi;
203
+ }
204
+ return undefined;
205
+ }
128
206
  /**
129
207
  * Detect the cadence closing each phrase. One Cadence per phrase that carries a
130
208
  * divisio. With no mode, targets/approach are still classified but no figure is
@@ -132,6 +210,7 @@ function bestFigure(observed, figures) {
132
210
  */
133
211
  export function detectCadences(phrases, modeData) {
134
212
  const cadences = [];
213
+ const finalMidi = chantFinalMidi(phrases);
135
214
  for (let pi = 0; pi < phrases.length; pi++) {
136
215
  const phrase = phrases[pi];
137
216
  if (!phrase.divisio)
@@ -162,6 +241,15 @@ export function detectCadences(phrases, modeData) {
162
241
  }
163
242
  }
164
243
  }
244
+ // The corpus classification, from THE shared key function — the engine does
245
+ // not compute this itself. `cadenceKeys` takes flat rows, so the window is
246
+ // projected into that shape; one phrase in, one event out.
247
+ const ev = cadenceKeys(window.map((w) => ({ phraseIndex: 0, midi: w.midi, divisio: null })), finalMidi)[0];
248
+ const shape = ev?.shape ?? [];
249
+ const arrival = ev?.arrival ?? 0;
250
+ // A 1-note phrase has a landing but no gesture: a real cadence with an
251
+ // empty shape, which is why `signature` is the key rather than null.
252
+ const signature = ev?.key ?? null;
165
253
  cadences.push({
166
254
  phraseIndex: pi,
167
255
  divisio,
@@ -172,6 +260,14 @@ export function detectCadences(phrases, modeData) {
172
260
  steps,
173
261
  confidence: Math.round(confidence * 100) / 100,
174
262
  notes: window.map((w) => [pi, w.syllableIndex, w.noteIndex]),
263
+ signature,
264
+ shape,
265
+ arrival,
266
+ // Left null here on purpose. Detection is a pure pass over the phrase
267
+ // tree; the corpus catalogue is generated data, and reaching for it from
268
+ // inside the detector would put a baked artifact in the detection path.
269
+ // buildScore joins it, where MODES is already joined.
270
+ finality: null,
175
271
  });
176
272
  }
177
273
  return cadences;
@@ -0,0 +1,21 @@
1
+ import type { ChantTabulaRow } from "../tabula.js";
2
+ export type AccidentalMode = "standard" | "heji" | "cents";
3
+ export type CentsBaseline = "pythagorean" | "et";
4
+ /** One note's intonation mark, for the emitter to place before/above the head. */
5
+ export interface AccidentalMark {
6
+ /** "glyph" → a notehead-preceding accidental; "cents" → a label floating above the staff. */
7
+ kind: "glyph" | "cents";
8
+ /** Glyph codepoint (kind "glyph") — a standard or HEJI accidental. */
9
+ glyph?: string;
10
+ /** Text label (kind "cents") — e.g. "−3.9". */
11
+ label?: string;
12
+ }
13
+ export type AccidentalGlyphSet = "modern" | "medieval";
14
+ /**
15
+ * Compute the intonation mark for each tabula row, or null where none applies.
16
+ * Repeat-suppression (standard mode) and per-phrase pitch-class suppression
17
+ * (cents mode) keep the staff from being carpeted.
18
+ * @throws Error when `heji` is asked of a non-just (meantone) tuning.
19
+ */
20
+ export declare function computeAccidentals(rows: ChantTabulaRow[], mode: AccidentalMode, centsBaseline?: CentsBaseline, glyphSet?: AccidentalGlyphSet): (AccidentalMark | null)[];
21
+ //# sourceMappingURL=accidentals.d.ts.map
@@ -0,0 +1,88 @@
1
+ import { pythagoreanCentsByPc } from "../../temper/scale.js";
2
+ // The Pythagorean chain's ET-cents deviation per pitch class, derived from the
3
+ // SAME E♭–G♯ chain the engine tunes with (temper/scale.ts) so baseline and
4
+ // engine can never disagree — a hardcoded copy once drifted and broke the
5
+ // heji channel for every flatted chant under the default tuning.
6
+ const PYTH_BASELINE = pythagoreanCentsByPc();
7
+ const GLYPHS_BY_SET = {
8
+ modern: { [-1]: "E260", 0: "E261", 1: "E262" },
9
+ medieval: { [-1]: "E9E0", 0: "E9E1", 1: "E9E3" },
10
+ };
11
+ // HEJI comma glyphs (Extended Helmholtz–Ellis, U+E2C0–). The syntonic-comma
12
+ // arrows: one comma down / up. Higher-order commas are out of scope for 0.2.
13
+ const HEJI_COMMA_DOWN = "E2C2"; // one syntonic comma lower
14
+ const HEJI_COMMA_UP = "E2C7"; // one syntonic comma higher
15
+ /** Deviation of a note from the Pythagorean chain, in cents. */
16
+ function fromPythagorean(row) {
17
+ return row.offset - (PYTH_BASELINE[row.pc] ?? 0);
18
+ }
19
+ /**
20
+ * Compute the intonation mark for each tabula row, or null where none applies.
21
+ * Repeat-suppression (standard mode) and per-phrase pitch-class suppression
22
+ * (cents mode) keep the staff from being carpeted.
23
+ * @throws Error when `heji` is asked of a non-just (meantone) tuning.
24
+ */
25
+ export function computeAccidentals(rows, mode, centsBaseline = "pythagorean", glyphSet = "modern") {
26
+ if (mode === "standard") {
27
+ // A glyph before any note whose pitch is explicitly inflected — but not on
28
+ // an immediately-repeated same pitch (restate only after another pitch).
29
+ let prevPc = null;
30
+ return rows.map((row) => {
31
+ const repeat = row.pc === prevPc;
32
+ prevPc = row.pc;
33
+ if (row.accidentalSource !== "explicit" || repeat)
34
+ return null;
35
+ // Draw the SIGN the source wrote, not this note's own alteration. In
36
+ // `fe(jx)cit(ih)` the flat is printed before the I while it governs J —
37
+ // where a sign is printed and which degree it alters are different facts.
38
+ // Reading `row.accidental` here drew nothing in that case, because the I
39
+ // is unaltered.
40
+ const sign = row.accidentalSign ?? row.accidental;
41
+ return { kind: "glyph", glyph: GLYPHS_BY_SET[glyphSet][sign] };
42
+ });
43
+ }
44
+ if (mode === "heji") {
45
+ // A tuning is just-expressible when its departures from Pythagorean are
46
+ // whole syntonic commas (±21.5¢). An irrational tempering (meantone) leaves
47
+ // fractional deviations everywhere — heji cannot notate it. Detect that.
48
+ const devs = rows.map(fromPythagorean);
49
+ const SYNTONIC = 21.506;
50
+ const tempered = devs.some((d) => {
51
+ if (Math.abs(d) < 0.5)
52
+ return false; // on the pure-fifth chain
53
+ const commas = d / SYNTONIC;
54
+ return Math.abs(commas - Math.round(commas)) > 0.1; // not a whole comma
55
+ });
56
+ if (tempered) {
57
+ throw new Error("inscriptio: accidentals \"heji\" needs a just-expressible tuning; the " +
58
+ "current temperament tempers by fractional commas (e.g. meantone). Use " +
59
+ "accidentals: \"cents\" for tempered tunings.");
60
+ }
61
+ return rows.map((row) => {
62
+ const d = fromPythagorean(row);
63
+ if (Math.abs(d) < 0.5)
64
+ return null; // Pythagorean baseline — no arrow
65
+ const commas = Math.round(d / SYNTONIC);
66
+ if (commas === 0)
67
+ return null;
68
+ return { kind: "glyph", glyph: commas < 0 ? HEJI_COMMA_DOWN : HEJI_COMMA_UP };
69
+ });
70
+ }
71
+ // cents — signed deviation label, stated once per pitch class PER PHRASE.
72
+ // A deviating pitch is labelled at its first appearance in each phrase and
73
+ // rides silently through the phrase's repeats; the next phrase restates it
74
+ // (the reader's memory is the phrase, and chant has no measures to carry it).
75
+ const seen = new Set();
76
+ return rows.map((row) => {
77
+ const value = centsBaseline === "et" ? row.offset : fromPythagorean(row);
78
+ if (Math.abs(value) < 0.5)
79
+ return null; // effectively in tune
80
+ const key = `${row.phraseIndex}:${row.pc}`;
81
+ if (seen.has(key))
82
+ return null; // already labelled in this phrase
83
+ seen.add(key);
84
+ const sign = value < 0 ? "−" : "+"; // real minus sign
85
+ return { kind: "cents", label: `${sign}${Math.abs(value).toFixed(1)}` };
86
+ });
87
+ }
88
+ //# sourceMappingURL=accidentals.js.map
@@ -0,0 +1,107 @@
1
+ /** The one ink. Everything black is this black. */
2
+ export declare const INK = "#111";
3
+ /** The liturgical red. Reserved for the CLAIM lines: the mode's in the
4
+ * tonarium, and — ruled 2026-08-11, amending the 07-29 reservation — the
5
+ * word's accent in the prosodia. The precedent is the score's own text
6
+ * apparatus: the dropcap and annotations have always been rubricated, and
7
+ * in the books red is the word's colour. Nothing else wears it. */
8
+ export declare const RUBRICA = "#9E2B25";
9
+ /** Stratum opacities: one ink, graded. The melody strata (wave, spark) sit
10
+ * under their annotations; a claim re-inks at full strength. */
11
+ export declare const STRATUM: {
12
+ readonly wave: 0.75;
13
+ readonly spark: 0.45;
14
+ readonly cadence: 1;
15
+ readonly letters: 0.62;
16
+ readonly label: 0.9;
17
+ readonly bracket: 0.3;
18
+ readonly rail: 0.24;
19
+ readonly margin: 0.38;
20
+ readonly block: 0.18;
21
+ readonly rule: 0.15;
22
+ };
23
+ /** Below this confidence nothing is drawn. Not faint — absent. */
24
+ export declare const CONF_FLOOR = 0.45;
25
+ /** THE nib — one pressure law: normalized velocity → stroke width. */
26
+ export declare const nib: (vn: number) => number;
27
+ /** The stroke ladder. A diagram picks a rung; it does not invent a width.
28
+ * Named for what the line DOES, so the choice is about meaning, not weight. */
29
+ export declare const STROKE: {
30
+ readonly hair: 0.55;
31
+ readonly fine: 0.75;
32
+ readonly firm: 1.05;
33
+ readonly heavy: 1.5;
34
+ };
35
+ /** The type scale, and it is THE PAGE'S: a modular scale on 15px stepped by
36
+ * the Pythagorean minor third, 32:27 — the same six `--micro` … `--display-size`
37
+ * that ../../../../docs/styles.css declares. So a wheel's label and a table's
38
+ * label at the same step are the same size.
39
+ *
40
+ * It was 9/11/13.5/17/21/26, derived from what the diagrams happened to use.
41
+ * That was honest when the page had no scale of its own; once the page took
42
+ * one, the two agreed on `title` alone and every drawn label sat 1.5–2px under
43
+ * the prose around it. ../../../../scripts/vendor-ink.mjs asserts the pairing
44
+ * now, so this cannot drift again silently.
45
+ *
46
+ * Nothing inside tonus reads STEP — the site's diagrams are its only consumer
47
+ * — so moving it changes no emitted SVG for a package consumer. */
48
+ export declare const STEP: {
49
+ readonly micro: 10.5;
50
+ readonly caption: 12.5;
51
+ readonly label: 15;
52
+ readonly body: 18;
53
+ readonly title: 21;
54
+ readonly display: 25;
55
+ };
56
+ /** The house faces. Junicode is the serif — the real face, not the Crimson Pro
57
+ * stand-in the early rounds carried; Plex Mono is the machine register. */
58
+ export declare const HOUSE_SERIF = "Junicode, 'Crimson Pro', Georgia, serif";
59
+ /** The machine register. IBM Plex Mono FIRST: the site loads and self-hosts
60
+ * that face, and with `ui-monospace` at the head of the stack every drawn
61
+ * label resolved to the system mono — SF Mono on macOS — beside page text in
62
+ * Plex. Two monospaces, same size, same column. */
63
+ export declare const HOUSE_MONO = "'IBM Plex Mono', ui-monospace, Menlo, monospace";
64
+ /** The fourth stack, and NOT a fourth face: neither text face carries the
65
+ * planetary or musical signs (see fonts/README.md), so a figure that draws one
66
+ * falls back to the system. Mirrors `--symbol` in ../../../../docs/styles.css
67
+ * — the same sharing-by-numbers seam as HOUSE_SERIF and HOUSE_MONO, and it was duplicated
68
+ * inline in mutatio.js as a local SIGN_FACE before this existed. */
69
+ export declare const HOUSE_SYMBOL: string;
70
+ /** The figures a FIGURE sets: Junicode's own, which are OLDSTYLE by default —
71
+ * they sit on the baseline with ascenders and descenders as lowercase letters
72
+ * do, so a number reads as part of a line of text rather than standing off it.
73
+ *
74
+ * `features` is empty on purpose, and that is the finding worth keeping: in
75
+ * Junicode `zero` IS the oldstyle glyph and `zero.lf` is the lining variant,
76
+ * so `onum` maps lining BACK to the default and does nothing unless `lnum` is
77
+ * already on. Setting it here looked correct and changed no pixel — measured
78
+ * identical advances, 423.2 either way. Reach for `lnum`/`tnum` when a column
79
+ * of digits must line up; that is the setting that does work.
80
+ *
81
+ * Nothing in the library's own emitters uses this yet. It lives here because
82
+ * this file is the ONE definition of the ink system and the site vendors it
83
+ * (../../../../scripts/vendor-ink.mjs) — a second copy in the site would be
84
+ * the drift this module exists to prevent. */
85
+ export declare const FIGURES: {
86
+ readonly family: "Junicode, 'Crimson Pro', Georgia, serif";
87
+ readonly features: "";
88
+ };
89
+ /** A scaled measure, at most two places and no trailing zeros: 1.8, not 1.80. */
90
+ export declare const sc: (v: number) => string;
91
+ /** XML-escape a string for an SVG attribute or text node. */
92
+ export declare const esc: (s: string) => string;
93
+ export type Pt = [number, number];
94
+ /** Sample one cubic Bézier segment (matches the generators' tessellation). */
95
+ export declare function sampleCubic(p1: Pt, c1: Pt, c2: Pt, p2: Pt, steps: number): Pt[];
96
+ /** Catmull–Rom through the points, sampled — the one curve idiom. */
97
+ export declare function crSamples(pts: Pt[], steps: number): Pt[];
98
+ /** Piecewise-linear velocity read along x between anchors; the pressure signal
99
+ * every pressure-bearing line shares. Null velocities read 0.3. */
100
+ export declare function velocityAt(velpts: Pt[]): (x: number) => number;
101
+ /** The subject's own velocity ceiling — pressure normalizes per subject, not
102
+ * to a corpus constant (the plates' frozen 0.62 was a session artifact). */
103
+ export declare function velocityCeiling(velocities: readonly (number | null | undefined)[]): number;
104
+ /** A ribbon polygon around sampled points: THE nib at `scale`, velocity as
105
+ * width — the one pressure stroke everything draws with. */
106
+ export declare function ribbonPath(samples: Pt[], vat: (x: number) => number, vmax: number, scale: number): string;
107
+ //# sourceMappingURL=atramentum.d.ts.map
@@ -0,0 +1,239 @@
1
+ // ---------------------------------------------------------------------------
2
+ // engines/score/emitters/atramentum — the ink: one drawing grammar
3
+ // ---------------------------------------------------------------------------
4
+ // THE GOVERNING INK SYSTEM (ruled 2026-07-29), extracted here so everything
5
+ // tonus draws shares one definition rather than agreeing by coincidence. The
6
+ // analysis tracks were its first consumer; the documentation site's diagrams
7
+ // are the second.
8
+ //
9
+ // - ONE INK. Every mark draws in the score's black; strata differ by OPACITY
10
+ // alone (STRATUM), never by hue. Rubrica is the only colour, and it is
11
+ // reserved for the claims: the mode line's in the tonarium, the accent's
12
+ // in the prosodia (ruled 2026-08-11, amending 07-29 — see RUBRICA below).
13
+ // - ONE NIB. Every pressure-bearing line shares one width law (`nib`): a
14
+ // normalized velocity becomes stroke width. Two marks at different opacities
15
+ // are the same stroke, not two different pens.
16
+ // - CONFIDENCE IS OPACITY, composing with the stratum: a weak claim fades, and
17
+ // below CONF_FLOOR it draws nothing at all. A thing tonus is unsure of does
18
+ // not get drawn faintly and hedged in a caption; it goes uninked.
19
+ //
20
+ // The design brief is "Dieter Rams designs a liturgical book" — functionalist
21
+ // restraint, one canonical accent, nothing decorative that is not also
22
+ // informative. STEP and STROKE exist so a diagram cannot quietly invent a
23
+ // fifteenth type size or a seventh hairline: it picks a step or it does not
24
+ // draw.
25
+ //
26
+ // ─── HOW THIS MEETS THE PAGE ───────────────────────────────────────────────
27
+ // The documentation site keeps a system of its own, stated at the top of
28
+ // ../../../../docs/styles.css. The two are separate and they AGREE BY SHARING
29
+ // NUMBERS,
30
+ // not by one importing the other:
31
+ //
32
+ // STEP mirrors the six CSS type steps (--micro … --display-size)
33
+ // INK mirrors --ink
34
+ // RUBRICA mirrors --rubrica
35
+ // HOUSE_* mirror --serif and --mono
36
+ //
37
+ // That seam is the thing to watch. A face or a step changed on one side and
38
+ // not the other does not fail — it renders, quietly, in the wrong thing. It
39
+ // has happened twice: HOUSE_MONO once listed ui-monospace first and drew
40
+ // every figure label in the system mono beside page text in Plex; HOUSE_SANS
41
+ // named a face no page loads, so the wheels' names and the tracks' letters
42
+ // came out in system-ui. Both are why the rule below is worth keeping literal.
43
+ //
44
+ // ─── THE FACES HAVE JOBS ───────────────────────────────────────────────────
45
+ // HOUSE_SERIF carries WORDS — a sign name, a month, a role, a gamut letter,
46
+ // a chironomy letter. HOUSE_MONO carries MACHINE DATA — hz, cents, ratios,
47
+ // ids. There is no third: if a label is a word, it is serif, wherever it is
48
+ // drawn. A figure that wants a face outside these two is asking the wrong
49
+ // question about its label.
50
+ //
51
+ // Colour reaches SVG the same way the score's does: a drawn figure names the
52
+ // site's token with its own value as the fallback — `var(--paper-dim,
53
+ // #F7F6F3)` — so the page can re-ink a figure it did not render, and the same
54
+ // figure standing alone still knows what it looks like.
55
+ //
56
+ // This module is INTERNAL. It is not part of the public API — the site reaches
57
+ // it through the vendored render subgraph, and outside consumers have no
58
+ // reason to draw in tonus's hand.
59
+ /** The one ink. Everything black is this black. */
60
+ export const INK = "#111";
61
+ /** The liturgical red. Reserved for the CLAIM lines: the mode's in the
62
+ * tonarium, and — ruled 2026-08-11, amending the 07-29 reservation — the
63
+ * word's accent in the prosodia. The precedent is the score's own text
64
+ * apparatus: the dropcap and annotations have always been rubricated, and
65
+ * in the books red is the word's colour. Nothing else wears it. */
66
+ export const RUBRICA = "#9E2B25";
67
+ /** Stratum opacities: one ink, graded. The melody strata (wave, spark) sit
68
+ * under their annotations; a claim re-inks at full strength. */
69
+ export const STRATUM = {
70
+ wave: 0.75, // the chironomy line — the gesture itself
71
+ spark: 0.45, // the tonarium melody — context, not message
72
+ cadence: 1.0, // the claim: the melody's ending, full ink
73
+ letters: 0.62, // Pierik letters
74
+ label: 0.9, // signature labels
75
+ bracket: 0.3, // the label's end-ticked tie
76
+ rail: 0.24, // the maneriae rails
77
+ margin: 0.38, // the "cad" margin word
78
+ block: 0.18, // the prosodia's melisma blocks — a soft fill, not a line
79
+ rule: 0.15, // the prosodia's single rule — quieter than a rail
80
+ };
81
+ /** Below this confidence nothing is drawn. Not faint — absent. */
82
+ export const CONF_FLOOR = 0.45;
83
+ /** THE nib — one pressure law: normalized velocity → stroke width. */
84
+ export const nib = (vn) => 0.5 + 1.5 * vn;
85
+ /** The stroke ladder. A diagram picks a rung; it does not invent a width.
86
+ * Named for what the line DOES, so the choice is about meaning, not weight. */
87
+ export const STROKE = {
88
+ hair: 0.55, // rails, grids, graticules — structure you read past
89
+ fine: 0.75, // the common diagram line
90
+ firm: 1.05, // a line making a claim (the mode line's weight)
91
+ heavy: 1.5, // an axis, a frame, a boundary
92
+ };
93
+ /** The type scale, and it is THE PAGE'S: a modular scale on 15px stepped by
94
+ * the Pythagorean minor third, 32:27 — the same six `--micro` … `--display-size`
95
+ * that ../../../../docs/styles.css declares. So a wheel's label and a table's
96
+ * label at the same step are the same size.
97
+ *
98
+ * It was 9/11/13.5/17/21/26, derived from what the diagrams happened to use.
99
+ * That was honest when the page had no scale of its own; once the page took
100
+ * one, the two agreed on `title` alone and every drawn label sat 1.5–2px under
101
+ * the prose around it. ../../../../scripts/vendor-ink.mjs asserts the pairing
102
+ * now, so this cannot drift again silently.
103
+ *
104
+ * Nothing inside tonus reads STEP — the site's diagrams are its only consumer
105
+ * — so moving it changes no emitted SVG for a package consumer. */
106
+ export const STEP = {
107
+ micro: 10.5, // superscripts, tick labels
108
+ caption: 12.5, // the workhorse — most diagram labels
109
+ label: 15, // named things
110
+ body: 18, // running text
111
+ title: 21, // a panel's name
112
+ display: 25, // the rare headline
113
+ };
114
+ /** The house faces. Junicode is the serif — the real face, not the Crimson Pro
115
+ * stand-in the early rounds carried; Plex Mono is the machine register. */
116
+ export const HOUSE_SERIF = "Junicode, 'Crimson Pro', Georgia, serif";
117
+ /* HOUSE_SANS is GONE. The house has three faces and each has a
118
+ * job: Junicode carries content and Latin, Plex Mono carries machine data,
119
+ * Jacquard is the wordmark. The sans stack named a fourth that no page loads,
120
+ * so every label reaching for it resolved to system-ui — SF Pro on macOS —
121
+ * and the wheels' names and the tracks' letters were set in it. Both now
122
+ * label in HOUSE_SERIF. Nothing should reintroduce a sans without a face to
123
+ * back it. */
124
+ /** The machine register. IBM Plex Mono FIRST: the site loads and self-hosts
125
+ * that face, and with `ui-monospace` at the head of the stack every drawn
126
+ * label resolved to the system mono — SF Mono on macOS — beside page text in
127
+ * Plex. Two monospaces, same size, same column. */
128
+ export const HOUSE_MONO = "'IBM Plex Mono', ui-monospace, Menlo, monospace";
129
+ /** The fourth stack, and NOT a fourth face: neither text face carries the
130
+ * planetary or musical signs (see fonts/README.md), so a figure that draws one
131
+ * falls back to the system. Mirrors `--symbol` in ../../../../docs/styles.css
132
+ * — the same sharing-by-numbers seam as HOUSE_SERIF and HOUSE_MONO, and it was duplicated
133
+ * inline in mutatio.js as a local SIGN_FACE before this existed. */
134
+ export const HOUSE_SYMBOL = "'Apple Symbols', 'Segoe UI Symbol', "
135
+ + "'Noto Sans Symbols2', " + HOUSE_SERIF;
136
+ /** The figures a FIGURE sets: Junicode's own, which are OLDSTYLE by default —
137
+ * they sit on the baseline with ascenders and descenders as lowercase letters
138
+ * do, so a number reads as part of a line of text rather than standing off it.
139
+ *
140
+ * `features` is empty on purpose, and that is the finding worth keeping: in
141
+ * Junicode `zero` IS the oldstyle glyph and `zero.lf` is the lining variant,
142
+ * so `onum` maps lining BACK to the default and does nothing unless `lnum` is
143
+ * already on. Setting it here looked correct and changed no pixel — measured
144
+ * identical advances, 423.2 either way. Reach for `lnum`/`tnum` when a column
145
+ * of digits must line up; that is the setting that does work.
146
+ *
147
+ * Nothing in the library's own emitters uses this yet. It lives here because
148
+ * this file is the ONE definition of the ink system and the site vendors it
149
+ * (../../../../scripts/vendor-ink.mjs) — a second copy in the site would be
150
+ * the drift this module exists to prevent. */
151
+ export const FIGURES = {
152
+ family: HOUSE_SERIF,
153
+ features: "",
154
+ };
155
+ /** A scaled measure, at most two places and no trailing zeros: 1.8, not 1.80. */
156
+ export const sc = (v) => Number(v.toFixed(2)).toString();
157
+ /** XML-escape a string for an SVG attribute or text node. */
158
+ export const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
159
+ .replace(/"/g, "&quot;");
160
+ /** Sample one cubic Bézier segment (matches the generators' tessellation). */
161
+ export function sampleCubic(p1, c1, c2, p2, steps) {
162
+ const out = [];
163
+ for (let k = 0; k < steps; k++) {
164
+ const t = k / steps;
165
+ const mt = 1 - t;
166
+ out.push([
167
+ mt ** 3 * p1[0] + 3 * mt * mt * t * c1[0] + 3 * mt * t * t * c2[0] + t ** 3 * p2[0],
168
+ mt ** 3 * p1[1] + 3 * mt * mt * t * c1[1] + 3 * mt * t * t * c2[1] + t ** 3 * p2[1],
169
+ ]);
170
+ }
171
+ return out;
172
+ }
173
+ /** Catmull–Rom through the points, sampled — the one curve idiom. */
174
+ export function crSamples(pts, steps) {
175
+ if (pts.length < 2)
176
+ return [...pts];
177
+ const P = [pts[0], ...pts, pts[pts.length - 1]];
178
+ const out = [];
179
+ for (let i = 1; i < P.length - 2; i++) {
180
+ const [p0, p1, p2, p3] = [P[i - 1], P[i], P[i + 1], P[i + 2]];
181
+ const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
182
+ const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
183
+ out.push(...sampleCubic(p1, c1, c2, p2, steps));
184
+ }
185
+ out.push(pts[pts.length - 1]);
186
+ return out;
187
+ }
188
+ /** Piecewise-linear velocity read along x between anchors; the pressure signal
189
+ * every pressure-bearing line shares. Null velocities read 0.3. */
190
+ export function velocityAt(velpts) {
191
+ return (x) => {
192
+ if (velpts.length === 0)
193
+ return 0.3;
194
+ if (x <= velpts[0][0])
195
+ return velpts[0][1];
196
+ if (x >= velpts[velpts.length - 1][0])
197
+ return velpts[velpts.length - 1][1];
198
+ for (let i = 0; i + 1 < velpts.length; i++) {
199
+ const [x0, v0] = velpts[i];
200
+ const [x1, v1] = velpts[i + 1];
201
+ if (x0 <= x && x <= x1)
202
+ return v0 + (v1 - v0) * ((x - x0) / Math.max(x1 - x0, 1e-6));
203
+ }
204
+ return 0.3;
205
+ };
206
+ }
207
+ /** The subject's own velocity ceiling — pressure normalizes per subject, not
208
+ * to a corpus constant (the plates' frozen 0.62 was a session artifact). */
209
+ export function velocityCeiling(velocities) {
210
+ let vmax = 0;
211
+ for (const v of velocities)
212
+ if (v != null && v > vmax)
213
+ vmax = v;
214
+ return vmax > 0 ? vmax : 0.62;
215
+ }
216
+ /** A ribbon polygon around sampled points: THE nib at `scale`, velocity as
217
+ * width — the one pressure stroke everything draws with. */
218
+ export function ribbonPath(samples, vat, vmax, scale) {
219
+ const top = [];
220
+ const bot = [];
221
+ const N = samples.length;
222
+ for (let i = 0; i < N; i++) {
223
+ const [x, y] = samples[i];
224
+ const [x0, y0] = samples[Math.max(i - 1, 0)];
225
+ const [x1, y1] = samples[Math.min(i + 1, N - 1)];
226
+ const dx = x1 - x0;
227
+ const dy = y1 - y0;
228
+ const L = Math.hypot(dx, dy) || 1;
229
+ const nx = -dy / L;
230
+ const ny = dx / L;
231
+ const vn = Math.min(vat(x) / vmax, 1);
232
+ const w = nib(vn) * scale;
233
+ top.push([x + (nx * w) / 2, y + (ny * w) / 2]);
234
+ bot.push([x - (nx * w) / 2, y - (ny * w) / 2]);
235
+ }
236
+ return "M " + top.map((p) => `${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(" L ") +
237
+ " L " + bot.reverse().map((p) => `${p[0].toFixed(1)} ${p[1].toFixed(1)}`).join(" L ") + " Z";
238
+ }
239
+ //# sourceMappingURL=atramentum.js.map
@@ -0,0 +1,62 @@
1
+ import type { ChantTabulaRow } from "../tabula.js";
2
+ /** What the caller knows at a candidate break point. */
3
+ export interface BreakQuery {
4
+ /** The row that would OPEN the next system. */
5
+ next: ChantTabulaRow;
6
+ /** Cursor position, in svg user units. */
7
+ x: number;
8
+ /** Rightmost usable x — the line's width less padding and any line-end mark. */
9
+ boundary: number;
10
+ /**
11
+ * Width of what must fit before the next legal break point: a phrase for
12
+ * quadrata, a syllable for moderna. Measured by the caller, never estimated
13
+ * here.
14
+ */
15
+ need: number;
16
+ /**
17
+ * Width of the whole sealed run starting at `next`, when `next.keepWithPrev`
18
+ * is set — the caller measures to the end of the `<nlba>` group. Ignored
19
+ * otherwise.
20
+ */
21
+ sealedRun?: number;
22
+ /** Where a fresh system's cursor starts, for judging whether a run fits at all. */
23
+ lineStart: number;
24
+ /**
25
+ * Set when the caller has ALREADY consumed `next.lineBreak` itself. Quadrata
26
+ * honours `z` in its own block (it must repeat the clef and place a custos
27
+ * before the staff advances), so asking here too would break the same system
28
+ * twice — measured, one Graduale chant drew 9 custos for 9 systems where 8 is
29
+ * correct, the last system needing none.
30
+ */
31
+ forcedHandled?: boolean;
32
+ }
33
+ /** Why a system ended — carried so a caller can vary the line-end mark. */
34
+ export type BreakReason = "forced" | "width" | "none";
35
+ export interface BreakVerdict {
36
+ break: boolean;
37
+ reason: BreakReason;
38
+ }
39
+ /**
40
+ * Decide whether a system ends before `next`.
41
+ *
42
+ * The order is the precedence order, and it is the whole contract:
43
+ *
44
+ * 1. `z` — an instruction, not a preference. Nothing overrides it, including
45
+ * a seal: an editor who wrote both meant both, and the break is the more
46
+ * specific statement.
47
+ * 2. `<nlba>` — forbids a break, unless honouring it would push the sealed run
48
+ * off the page. A seal is a preference about where a line ends; staying
49
+ * inside the canvas is the stronger claim.
50
+ * 3. Width — the ordinary case.
51
+ */
52
+ export declare function decideBreak(q: BreakQuery): BreakVerdict;
53
+ /**
54
+ * Width of the sealed run beginning at `from`, by the caller's own measure.
55
+ *
56
+ * The head of a sealed group must be tested against the WHOLE group: admit the
57
+ * head alone and every seam after it is sealed, so the line can no longer break
58
+ * and the overflow is unrecoverable. Measured across the 35 Graduale chants that
59
+ * carry `<nlba>`, measuring one syllable instead of the run broke 37 seams.
60
+ */
61
+ export declare function sealedRunWidth(rows: ChantTabulaRow[], from: number, widthOf: (index: number) => number): number;
62
+ //# sourceMappingURL=breaking.d.ts.map