tonus 0.9.0 → 0.10.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 (51) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +3 -3
  3. package/dist/census.d.ts +4 -0
  4. package/dist/census.js +23 -0
  5. package/dist/corpus.d.ts +4 -0
  6. package/dist/corpus.js +17 -0
  7. package/dist/data/am.js +10220 -3006
  8. package/dist/data/attestation.js +38640 -4568
  9. package/dist/data/attestation.json +38639 -4567
  10. package/dist/data/census.d.ts +2 -2
  11. package/dist/data/census.js +4 -4
  12. package/dist/data/corpus-overlap.js +5 -118
  13. package/dist/data/gr.d.ts +29 -2
  14. package/dist/data/gr.js +2436 -6411
  15. package/dist/data/kyriale.d.ts +1 -1
  16. package/dist/data/kyriale.js +38 -38
  17. package/dist/data/la.js +12369 -1103
  18. package/dist/data/lh.js +3139 -158
  19. package/dist/data/lu.js +17332 -2379
  20. package/dist/data/nocturnale-romanum.js +10656 -1899
  21. package/dist/data/psm.js +463 -33
  22. package/dist/data/types.d.ts +1 -0
  23. package/dist/engines/chant/chant.js +60 -13
  24. package/dist/engines/chant/data/masses.js +18 -18
  25. package/dist/engines/chant/ordinary.js +12 -5
  26. package/dist/engines/chant/psalm.js +4 -0
  27. package/dist/engines/chant/types.d.ts +32 -4
  28. package/dist/engines/chant/types.js +47 -4
  29. package/dist/engines/score/emitters/moderna.js +150 -12
  30. package/dist/engines/score/infer.js +1 -1
  31. package/dist/engines/temper/gabc.d.ts +12 -0
  32. package/dist/engines/temper/gabc.js +51 -18
  33. package/dist/index.d.ts +5 -9
  34. package/dist/inscriptio.d.ts +5 -0
  35. package/dist/inscriptio.js +23 -0
  36. package/dist/score.d.ts +6 -0
  37. package/dist/score.js +20 -0
  38. package/docs/api/calendar.md +4 -4
  39. package/docs/api/census.md +31 -22
  40. package/docs/api/chant.md +102 -82
  41. package/docs/api/heavens.md +7 -7
  42. package/docs/api/index.md +40 -6
  43. package/docs/api/score.md +44 -44
  44. package/docs/api/tuning.md +14 -14
  45. package/package.json +22 -4
  46. package/dist/data/ams.d.ts +0 -5
  47. package/dist/data/ams.js +0 -122
  48. package/dist/data/cot.d.ts +0 -5
  49. package/dist/data/cot.js +0 -172
  50. package/dist/data/cse.d.ts +0 -5
  51. package/dist/data/cse.js +0 -122
@@ -30,7 +30,53 @@ const G = {
30
30
  noteheadBlack: "E0A4",
31
31
  augmentationDot: "E1E7",
32
32
  quilisma: "EA20", // medRenQuilismaCMN
33
+ accidentalFlat: "E260",
34
+ accidentalNatural: "E261",
35
+ accidentalSharp: "E262",
33
36
  };
37
+ // The staff holds nine written diatonic slots — bottom line E4 to top line F5
38
+ // — which under the transposing gClef8vb sounds E3–F4, MIDI 52–65.
39
+ const STAFF_LO = 52;
40
+ const STAFF_HI = 65;
41
+ /**
42
+ * How many octaves to lift the written notes so the chant sits on the staff.
43
+ *
44
+ * ONE clef, always: moderna draws a gClef8vb and nothing else. What floats is
45
+ * the written octave, the same move the orreliquum hand already makes when it
46
+ * lifts a chant by whole octaves onto the gamut's fingers.
47
+ *
48
+ * It earns its place going DOWN, which is the direction chant is transposed:
49
+ * the corpus already sounds below this staff (median 50–62 against 52–65), so
50
+ * a transposition of −4 puts 43.6% of all notes below the bottom line, some
51
+ * four ledger lines deep. Choosing the octave takes that to 15.8%. Upward it
52
+ * changes little, because chant does not sit high to begin with.
53
+ *
54
+ * Whole octaves only, and the same lift for the whole chant: shifting by
55
+ * anything else would respell the music, and shifting per-phrase would make
56
+ * one page read in two registers.
57
+ *
58
+ * The honest limit: an 8vb clef already says "sounds an octave lower", so a
59
+ * further lift understates the true pitch with nothing on the page confessing
60
+ * it. That is the accepted cost of keeping one clef; `notatio`'s `spn` remains
61
+ * the authority on what actually sounds.
62
+ */
63
+ function pickOctaveLift(rows) {
64
+ const midis = rows.map((r) => r.midi).filter((m) => typeof m === "number");
65
+ if (midis.length === 0)
66
+ return 0;
67
+ let best = 0;
68
+ let fewest = Infinity;
69
+ // Nearest first, so a tie keeps the chant where it was written.
70
+ for (const lift of [0, 1, -1]) {
71
+ const shifted = lift * 12;
72
+ const off = midis.filter((m) => m + shifted < STAFF_LO || m + shifted > STAFF_HI).length;
73
+ if (off < fewest) {
74
+ fewest = off;
75
+ best = lift;
76
+ }
77
+ }
78
+ return best;
79
+ }
34
80
  // ── geometry, as a function of the staff ──────────────────────────────────
35
81
  //
36
82
  // THE CONTRACT (ruled 2026-08-04): `staffHeight` is the height of the STAFF
@@ -114,14 +160,42 @@ export function metrics(staffHeight) {
114
160
  }
115
161
  const LETTERS = { C: 0, D: 1, E: 2, F: 3, G: 4, A: 5, B: 6 };
116
162
  const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
117
- /** Written y for a scientific pitch name on the treble-8 staff (bottom line E4). */
118
- function writtenY(spn, systemY, gm) {
119
- const m = /([A-G])[#b]?(-?\d)/.exec(spn);
163
+ /**
164
+ * Written y for a scientific pitch name on the treble-8 staff (bottom line
165
+ * written E4, sounding E3).
166
+ *
167
+ * `spn` is a SOUNDING pitch; the gClef8vb this species draws transposes, so the
168
+ * note is WRITTEN an octave above where it sounds. Hence the `+ 1` below:
169
+ * sounding G3 is written G4 and sits on the second line, where mode VII's final
170
+ * belongs. The reference generator folded that lift into a short reference term
171
+ * (`4 * 7` against a note term carrying `+2`) because it was fed pitches an
172
+ * octave below today's corpus; the two errors cancelled. Stated once, plainly,
173
+ * both halves now agree — and the chant lands on the staff instead of a sixth
174
+ * above it.
175
+ *
176
+ * `lift` is the further whole-octave shift `pickOctaveLift` chose for this
177
+ * chant, so a low or downward-transposed piece is written where it can be read
178
+ * rather than on a stack of ledger lines.
179
+ *
180
+ * The ACCIDENTAL is read but never changes the slot: a flat and its natural
181
+ * share one line (B-flat sits on the B line), which is why the letter alone
182
+ * fixes `steps`. It is returned separately so the caller can draw the sign.
183
+ * Discarding it was a silent wrong pitch — under transposition `Ab3` and `A3`
184
+ * landed on the same slot with nothing to tell them apart.
185
+ */
186
+ function writtenY(spn, systemY, gm, lift = 0) {
187
+ const m = /([A-G])([#b]?)(-?\d)/.exec(spn);
120
188
  if (!m)
121
- return { y: systemY + gm.MTOP + 4 * gm.MSP, steps: 0 };
122
- const di = (Number(m[2]) + 2) * 7 + LETTERS[m[1]];
123
- const steps = di - (4 * 7 + LETTERS["E"]); // relative to bottom line E4
124
- return { y: systemY + gm.MTOP + 4 * gm.MSP - steps * (gm.MSP / 2), steps };
189
+ return { y: systemY + gm.MTOP + 4 * gm.MSP, steps: 0, accidental: 0 };
190
+ const accidental = m[2] === "b" ? -1 : m[2] === "#" ? 1 : 0;
191
+ const written = Number(m[3]) + 1 + lift; // the 8vb clef writes an octave up
192
+ const di = (written + 2) * 7 + LETTERS[m[1]];
193
+ const steps = di - ((4 + 2) * 7 + LETTERS["E"]); // relative to bottom line E4
194
+ return {
195
+ y: systemY + gm.MTOP + 4 * gm.MSP - steps * (gm.MSP / 2),
196
+ steps,
197
+ accidental,
198
+ };
125
199
  }
126
200
  // Moderna's ink, as a CSS custom property with the house default as fallback —
127
201
  // the same three properties quadrata emits, so one stylesheet themes both
@@ -229,6 +303,35 @@ function accidentalWidth(code, gm) {
229
303
  function accidentalMark(x, y, code, gm) {
230
304
  return classedGlyph("accidental", code, x - gm.NH_W / 2 - accidentalWidth(code, gm), y, (gm.SCALE * 0.62));
231
305
  }
306
+ /**
307
+ * The short lines that carry a notehead outside the five, as quadrata's
308
+ * `ledger` does: one per LINE position the note has passed, not one per step.
309
+ *
310
+ * `steps` counts diatonic slots up from the bottom line, so the staff occupies
311
+ * 0–8 and its lines are the even positions. A note at 10 gets a ledger at 10;
312
+ * at 11 (a space above that) it still gets only the one at 10. Below, the same
313
+ * downward: -1 draws nothing, -2 draws at -2.
314
+ *
315
+ * Rendered wider than the head by a quarter of its width on each side, the
316
+ * proportion quadrata uses, so the line reads as staff rather than as a tick.
317
+ */
318
+ function ledgerLines(steps, mx, systemY, gm) {
319
+ if (steps >= 0 && steps <= 8)
320
+ return [];
321
+ const half = gm.NH_W / 2 + gm.NH_W * 0.25;
322
+ const out = [];
323
+ const emit = (lp) => {
324
+ const ly = systemY + gm.MTOP + 4 * gm.MSP - lp * (gm.MSP / 2);
325
+ out.push(`<line class="ledger" x1="${(mx - half).toFixed(2)}" y1="${ly.toFixed(2)}" ` +
326
+ `x2="${(mx + half).toFixed(2)}" y2="${ly.toFixed(2)}" ` +
327
+ `stroke="${INK}" stroke-width="0.7"/>`);
328
+ };
329
+ for (let lp = 10; lp <= steps; lp += 2)
330
+ emit(lp);
331
+ for (let lp = -2; lp >= steps; lp -= 2)
332
+ emit(lp);
333
+ return out;
334
+ }
232
335
  const DIV_KIND = {
233
336
  "`": "tick", ",": "tick", ";": "half", ":": "full", "::": "double",
234
337
  };
@@ -290,6 +393,17 @@ export function toModerna(rows, chant, options = {}) {
290
393
  const markByRow = new Map();
291
394
  rows.forEach((row, i) => { const m = marks[i]; if (m)
292
395
  markByRow.set(row, m); });
396
+ // One lift for the whole chant, chosen before any note is placed — a page
397
+ // that changed register partway would read as two pieces.
398
+ const lift = pickOctaveLift(rows);
399
+ // The pitches an engine-computed sign already accounts for. `degreeSpn` is
400
+ // the mark's own statement of which pitch it alters, so this is the engine
401
+ // speaking rather than a second guess at its scoping rules — the spelled
402
+ // path below stays silent wherever a written sign has already spoken.
403
+ const alteredSpns = new Set();
404
+ for (const m of marks)
405
+ if (m?.kind === "glyph" && m.degreeSpn)
406
+ alteredSpns.add(m.degreeSpn);
293
407
  // ── Front matter ── The same official display quadrata sets (title centered
294
408
  // over the score, the genus/mode mark stacked at the left margin — the
295
409
  // `annotation: "auto"` params), honoured here so both species open a piece
@@ -316,6 +430,10 @@ export function toModerna(rows, chant, options = {}) {
316
430
  }
317
431
  const body = [];
318
432
  const slurs = [];
433
+ // Ledger lines, collected as the notes are placed but emitted with the STAFF
434
+ // — they are staff, continuing it for one note, and must lie behind the
435
+ // notehead rather than cross it. `staff` is joined before `body` below.
436
+ const ledgers = [];
319
437
  const lyricSvgs = [];
320
438
  const lyricRuns = [];
321
439
  const placements = [];
@@ -415,25 +533,45 @@ export function toModerna(rows, chant, options = {}) {
415
533
  const mk = markByRow.get(r);
416
534
  if (mk?.kind === "glyph")
417
535
  nx += accidentalWidth(mk.glyph, gm); // room for the accidental
418
- const { y, steps } = writtenY(r.spn, systemY, gm);
419
- notePos.push({ mx: nx, my: y, steps });
536
+ const { y, steps, accidental } = writtenY(r.spn, systemY, gm, lift);
537
+ // A sign the SPELLING demands. `computeAccidentals` speaks for the marks
538
+ // written in the GABC (`accidentalSource: "explicit"`); a transposed
539
+ // score carries its accidentals in `spn` alone, where nothing was ever
540
+ // written, so those would otherwise go undrawn and the note would read a
541
+ // semitone off with no sign to say so.
542
+ //
543
+ // The guard is `alteredSpns`, NOT this row's own `mk`. A written sign is
544
+ // carried by the note BEFORE the one it governs — on gregobase:1180 the
545
+ // three signs sit on rows 27/39/62 and their B-flats on 31/40/66, wholly
546
+ // disjoint — so asking "does this row carry a mark?" says no on the very
547
+ // note the sign already covers, and drew a second glyph for each.
548
+ const spelled = accidental !== 0 && !alteredSpns.has(r.spn)
549
+ ? (accidental === -1 ? G.accidentalFlat : G.accidentalSharp)
550
+ : null;
551
+ if (spelled)
552
+ nx += accidentalWidth(spelled, gm);
553
+ notePos.push({ mx: nx, my: y, steps, spelled });
420
554
  nx += gm.ADV + 4.6 * r.mora;
421
555
  }
422
556
  const notesW = nx - x - gm.ADV + gm.NH_W / 2 + 2;
423
557
  const sylW = Math.max(notesW, textW(lyr));
424
558
  // Draw notes.
425
559
  srows.forEach((r, i) => {
426
- const { mx, my, steps } = notePos[i];
560
+ const { mx, my, steps, spelled } = notePos[i];
427
561
  const onLine = steps % 2 === 0;
562
+ ledgers.push(...ledgerLines(steps, mx, systemY, gm));
428
563
  if (r.quilisma)
429
564
  body.push(quilismaMark(mx, my, gm));
430
565
  const mk = markByRow.get(r);
431
566
  if (mk?.kind === "glyph") {
432
567
  // Vertically the sign belongs to the pitch it alters, not to the note
433
568
  // it was written before; horizontally it stays at this note's column.
434
- const ay = mk.degreeSpn ? writtenY(mk.degreeSpn, systemY, gm).y : my;
569
+ const ay = mk.degreeSpn ? writtenY(mk.degreeSpn, systemY, gm, lift).y : my;
435
570
  body.push(accidentalMark(mx, ay, mk.glyph, gm));
436
571
  }
572
+ // A spelled accidental alters THIS note, so it sits on this note's line.
573
+ else if (spelled)
574
+ body.push(accidentalMark(mx, my, spelled, gm));
437
575
  else if (mk?.kind === "cents") {
438
576
  // Cents labels float in a band above the staff (not glued to the
439
577
  // head) — an analytic overlay, not an engraving mark.
@@ -599,7 +737,7 @@ export function toModerna(rows, chant, options = {}) {
599
737
  `width="${W}" height="${height}" class="tonus-chant moderna">${svgTitle}` +
600
738
  lyricEmbed +
601
739
  header.join("") +
602
- staff.join("") + clefSvgs.join("") + body.join("") + slurs.join("") + lyricSvgs.join("") +
740
+ staff.join("") + ledgers.join("") + clefSvgs.join("") + body.join("") + slurs.join("") + lyricSvgs.join("") +
603
741
  `</svg>`;
604
742
  const geometry = placements.map((pl) => ({
605
743
  phraseIndex: pl.row.phraseIndex,
@@ -5,7 +5,7 @@ import { MODES } from "../temper/modes.js";
5
5
  // phrase: the Gloria's "Et in terra pax" (the celebrant sings "Gloria in
6
6
  // excelsis") and the Credo's "Patrem omnipotentem" ("Credo in unum Deum").
7
7
  const ORDINARY_INCIPITS = [
8
- [/^kyrie/i, "ky"],
8
+ [/^kyrie/i, "ke"],
9
9
  [/^gloria/i, "gl"],
10
10
  [/^etinterra/i, "gl"],
11
11
  [/^credo/i, "cr"],
@@ -1,3 +1,15 @@
1
+ /**
2
+ * The GABC letter for a MIDI pitch under `clef` — with `x` appended when the
3
+ * pitch is a B-flat (`jx`, the flat sign then the note).
4
+ *
5
+ * B-flat is the ONE accidental chant sings — the b molle of the medieval gamut,
6
+ * the whole reason `parse.ts` carries a flat state machine and the `b` clefs
7
+ * exist. This function used to throw "is not diatonic" on it, which made the
8
+ * apparent inverse of `gabcToMidi` unable to spell a pitch the parser reads on
9
+ * every other page. Every other chromatic pitch class still throws: those are
10
+ * outside the gamut, and inventing a spelling for them would be a worse answer
11
+ * than refusing.
12
+ */
1
13
  export declare function midiToGabc(midi: number, clef?: string): string;
2
14
  export declare function gabcToMidi(letter: string, clef?: string): number;
3
15
  export declare function pcToGabc(pc: number, clef?: string, oct?: number): string;
@@ -18,26 +18,52 @@ const LETTERS = "abcdefghijklm";
18
18
  // A higher c-clef (c4 vs c1) moves "do" up the staff, so the same letter reads a
19
19
  // lower pitch — hence doIdx climbs 3→5→7→9 across c1→c4. The f-clefs anchor on
20
20
  // fa (MIDI 53) and are used for lower-tessitura chant.
21
- const CLEFS = {
22
- c1: { doMidi: 60, doIdx: 3 },
23
- c2: { doMidi: 60, doIdx: 5 },
24
- c3: { doMidi: 60, doIdx: 7 },
25
- c4: { doMidi: 60, doIdx: 9 },
26
- // f-clefs anchor fa on the named line. Staff lines (bottom→top) sit at
27
- // letters d/f/h/j (per the Gregorio spec: 2-line staff = a–i, 3-line = a–k,
28
- // 4-line = a–m, pinning the lines at slots 3/5/7/9), so f3 puts fa at 'h'
29
- // (7) and f4 at 'j' (9). Previous values (5 and 3) were off by a third and
30
- // read every f-clef chant at the wrong staff position.
31
- f3: { doMidi: 53, doIdx: 7 },
32
- f4: { doMidi: 53, doIdx: 9 },
33
- };
21
+ // The staff-line slots the clefs anchor to, low to high: letters d/f/h/j per
22
+ // the Gregorio spec (2-line staff = a–i, 3-line = a–k, 4-line = a–m).
23
+ const LINE_SLOTS = [3, 5, 7, 9];
24
+ // Every clef GABC can declare, built from the same two rules rather than listed
25
+ // by hand: a c-clef puts "do" (MIDI 60) on its named line, an f-clef puts "fa"
26
+ // (MIDI 53) there. cN/fN for N in 1–4, plus the `b` variants (cbN/fbN) that
27
+ // additionally declare a B-flat key signature.
28
+ //
29
+ // This table used to hold six entries — c1–c4, f3, f4 — while `parse.ts`'s
30
+ // CLEF_OFFSETS held all sixteen. Two tables disagreeing about what a clef is,
31
+ // with the smaller one throwing "Unknown clef" on inputs the parser accepts
32
+ // happily. Deriving them removes the chance of a third disagreement.
33
+ const CLEFS = {};
34
+ for (let n = 1; n <= 4; n++) {
35
+ const doIdx = LINE_SLOTS[n - 1];
36
+ // An f-clef names fa's line, and fa is the 4th diatonic step (index 3), so
37
+ // "do" sits three slots below the named line — which is what doMidi 53
38
+ // (the F below middle C) is measured from.
39
+ CLEFS[`c${n}`] = { doMidi: 60, doIdx };
40
+ CLEFS[`cb${n}`] = { doMidi: 60, doIdx };
41
+ CLEFS[`f${n}`] = { doMidi: 53, doIdx };
42
+ CLEFS[`fb${n}`] = { doMidi: 53, doIdx };
43
+ }
44
+ /**
45
+ * The GABC letter for a MIDI pitch under `clef` — with `x` appended when the
46
+ * pitch is a B-flat (`jx`, the flat sign then the note).
47
+ *
48
+ * B-flat is the ONE accidental chant sings — the b molle of the medieval gamut,
49
+ * the whole reason `parse.ts` carries a flat state machine and the `b` clefs
50
+ * exist. This function used to throw "is not diatonic" on it, which made the
51
+ * apparent inverse of `gabcToMidi` unable to spell a pitch the parser reads on
52
+ * every other page. Every other chromatic pitch class still throws: those are
53
+ * outside the gamut, and inventing a spelling for them would be a worse answer
54
+ * than refusing.
55
+ */
34
56
  export function midiToGabc(midi, clef = "c4") {
35
57
  const def = CLEFS[clef];
36
58
  if (!def)
37
59
  throw new Error(`Unknown clef: ${clef}`);
38
- const octave = Math.floor(midi / 12) - 1;
39
60
  const pc = midi % 12;
40
- const diatIdx = DIATONIC.indexOf(pc);
61
+ // A flat is spelled on the staff slot of the natural ABOVE it: B-flat takes
62
+ // B's line with an `x`. Resolve to that natural, then mark the result.
63
+ const flat = pc === 10;
64
+ const natural = flat ? midi + 1 : midi;
65
+ const octave = Math.floor(natural / 12) - 1;
66
+ const diatIdx = DIATONIC.indexOf(natural % 12);
41
67
  if (diatIdx === -1)
42
68
  throw new Error(`MIDI note ${midi} (pc ${pc}) is not diatonic`);
43
69
  const doOctave = Math.floor(def.doMidi / 12) - 1;
@@ -45,13 +71,19 @@ export function midiToGabc(midi, clef = "c4") {
45
71
  const letter = LETTERS[staffPos];
46
72
  if (!letter)
47
73
  throw new Error(`MIDI ${midi} out of GABC range for clef ${clef}`);
48
- return letter;
74
+ return flat ? `${letter}x` : letter;
49
75
  }
50
76
  export function gabcToMidi(letter, clef = "c4") {
51
77
  const def = CLEFS[clef];
52
78
  if (!def)
53
79
  throw new Error(`Unknown clef: ${clef}`);
54
- const staffPos = LETTERS.indexOf(letter.toLowerCase());
80
+ // Accept the `x` that `midiToGabc` emits, so the two stay inverses. A flat
81
+ // lowers the natural it marks by a semitone; only B carries one in the gamut,
82
+ // but the arithmetic is written once for whatever letter arrives.
83
+ const raw = letter.toLowerCase();
84
+ const flat = raw.endsWith("x");
85
+ const bare = flat ? raw.slice(0, -1) : raw;
86
+ const staffPos = LETTERS.indexOf(bare);
55
87
  if (staffPos === -1)
56
88
  throw new Error(`Unknown GABC letter: ${letter}`);
57
89
  // Diatonic steps from "do", split into whole octaves (÷7) and the step within
@@ -61,7 +93,8 @@ export function gabcToMidi(letter, clef = "c4") {
61
93
  const octOffset = Math.floor(stepsFromDo / 7);
62
94
  const diatStep = ((stepsFromDo % 7) + 7) % 7;
63
95
  const doOctave = Math.floor(def.doMidi / 12) - 1;
64
- return (doOctave + octOffset + 1) * 12 + DIATONIC[diatStep];
96
+ const natural = (doOctave + octOffset + 1) * 12 + DIATONIC[diatStep];
97
+ return flat ? natural - 1 : natural;
65
98
  }
66
99
  export function pcToGabc(pc, clef = "c4", oct = 0) {
67
100
  const def = CLEFS[clef];
package/dist/index.d.ts CHANGED
@@ -11,20 +11,16 @@ import { getCosmos } from "./engines/planet/planet.js";
11
11
  import { buildHarmonia } from "./engines/harmonia/api.js";
12
12
  import { getCensus } from "./engines/census/census.js";
13
13
  import type { FeastQuery, Feast, Pascha, Season, Grade } from "./engines/cal/types.js";
14
- import type { CantusQuery, Chant, OrdinaryChant, PropriumQuery, OrdinariumQuery, OfficiumQuery, PsalmusQuery, Corpus, GenusCount, ModeCount, SharedCount, CorpusLedger, CorpusFullCount, CorpusQuery } from "./engines/chant/types.js";
14
+ import type { CantusQuery, Chant, OrdinaryChant, PropriumQuery, OrdinariumQuery, OfficiumQuery, PsalmusQuery, Corpus, CorpusLedger, CorpusQuery } from "./engines/chant/types.js";
15
15
  import type { TemperamentumInput, Temperamentum, Tuning, TemperamentumOpts, Pitch, PitchInput, Step, Neume, NeumeShape, Interval, ModeData, CadenceFigure, Modus, TunedNote, GamutOptions, Tonus, TonusOpts } from "./engines/temper/api.js";
16
- import type { Score, ScoreOpts, PondusInput, PondusOpts, AccentusInput, AccentusOpts, Cadence, CadenceTarget, CadenceApproach, Modulation } from "./engines/score/api.js";
17
- import type { InscriptioOpts, Inscriptio, NoteGeometry, FontSpec, FontSlot, FontEmbed } from "./engines/score/inscriptio.js";
18
- import type { ChantTabulaRow } from "./engines/score/tabula.js";
19
- import type { Imprint, Attractor, VowelAttractor, ModalAffinity } from "./engines/imprint.js";
20
- import type { Metrics, RhythmicProfile, NoteRange, CadenceDistribution } from "./engines/score/metrics.js";
16
+ import type { Score, ScoreOpts, PondusInput, PondusOpts, AccentusInput, AccentusOpts, Cadence, Modulation } from "./engines/score/api.js";
17
+ import type { Imprint } from "./engines/imprint.js";
18
+ import type { Metrics } from "./engines/score/metrics.js";
21
19
  import type { Harmony, HarmoniaOpts, VoicedBody, VoicedAspect, Frame, Author } from "./engines/harmonia/api.js";
22
20
  import type { HarmonyTabulaRow } from "./engines/harmonia/tabula.js";
23
21
  import type { PlanetVowel } from "./engines/harmonia/data/vowels.js";
24
- import type { Note, Performance, Phrase, Syllable, LyricRun, RestEvent, ParseError, ArsisThesis, RhythmicType, CompoundBeat } from "./engines/score/types.js";
25
22
  import type { VoicedPitch } from "./engines/harmonia/voice.js";
26
23
  import type { Cosmos, CosmosQuery, Body, BodyName, Aspect } from "./engines/planet/types.js";
27
- import type { Census, CensusQuery, CensusBy, CensusGroup, CensusGroupProfile, CensusNeighbor } from "./engines/census/types.js";
28
24
  declare const tonus: {
29
25
  festum: typeof getFeast;
30
26
  pascha: typeof getPascha;
@@ -58,5 +54,5 @@ export { CADENTIAE, CADENTIAE_POPULATION } from "./data/cadentiae.js";
58
54
  export type { CadentiaFamilia } from "./data/cadentiae.js";
59
55
  export { ZODIACA } from "./engines/harmonia/data/zodiac.js";
60
56
  export { CENSUS_GROUPS, CENSUS_ORDER } from "./data/census.js";
61
- export type { Feast, FeastQuery, Pascha, Season, Grade, Chant, CantusQuery, OrdinaryChant, PropriumQuery, OrdinariumQuery, OfficiumQuery, PsalmusQuery, Corpus, GenusCount, ModeCount, SharedCount, CorpusLedger, CorpusFullCount, CorpusQuery, Census, CensusQuery, CensusBy, CensusGroup, CensusGroupProfile, CensusNeighbor, Temperamentum, TemperamentumInput, TemperamentumOpts, Tuning, Pitch, PitchInput, Step, Neume, NeumeShape, Interval, ModeData, CadenceFigure, Modus, TunedNote, GamutOptions, Tonus, TonusOpts, Score, ScoreOpts, PondusInput, PondusOpts, AccentusInput, AccentusOpts, Cadence, CadenceTarget, CadenceApproach, Modulation, InscriptioOpts, Inscriptio, NoteGeometry, FontSpec, FontSlot, FontEmbed, ChantTabulaRow, Note, Performance, Phrase, Syllable, LyricRun, RestEvent, ParseError, ArsisThesis, RhythmicType, CompoundBeat, VoicedPitch, Cosmos, CosmosQuery, Body, BodyName, Aspect, Imprint, Attractor, VowelAttractor, ModalAffinity, Metrics, RhythmicProfile, NoteRange, CadenceDistribution, Harmony, HarmoniaOpts, VoicedBody, VoicedAspect, Frame, Author, HarmonyTabulaRow, PlanetVowel, };
57
+ export type { Feast, FeastQuery, Pascha, Season, Grade, Chant, CantusQuery, OrdinaryChant, PropriumQuery, OrdinariumQuery, OfficiumQuery, PsalmusQuery, Corpus, CorpusLedger, CorpusQuery, Temperamentum, TemperamentumInput, TemperamentumOpts, Tuning, Pitch, PitchInput, Step, Neume, NeumeShape, Interval, ModeData, CadenceFigure, Modus, TunedNote, GamutOptions, Tonus, TonusOpts, Score, ScoreOpts, PondusInput, PondusOpts, AccentusInput, AccentusOpts, Cadence, Modulation, VoicedPitch, Cosmos, CosmosQuery, Body, BodyName, Aspect, Imprint, Metrics, Harmony, HarmoniaOpts, VoicedBody, VoicedAspect, Frame, Author, HarmonyTabulaRow, PlanetVowel };
62
58
  //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,5 @@
1
+ export { inscriptio } from "./engines/score/inscriptio.js";
2
+ export type { InscriptioOpts, Inscriptio, NoteGeometry, FontSpec, FontSlot, FontEmbed, Theme, ThemeColors, TrackName, TrackData, } from "./engines/score/inscriptio.js";
3
+ export type { ChantTabulaRow } from "./engines/score/tabula.js";
4
+ export type { Score } from "./engines/score/api.js";
5
+ //# sourceMappingURL=inscriptio.d.ts.map
@@ -0,0 +1,23 @@
1
+ // ---------------------------------------------------------------------------
2
+ // tonus/inscriptio — the drawing entry point
3
+ // ---------------------------------------------------------------------------
4
+ // The rendering boundary, made addressable: `score` analyzes, `inscriptio`
5
+ // draws. Everything reachable from here consumes a Score and produces SVG plus
6
+ // the geometry contract; nothing here is read by an analysis pass.
7
+ //
8
+ // The root namespace still carries `inscriptio` — the export law puts verbs on
9
+ // the namespace, and this module does not repeal it. What the entry adds is a
10
+ // place to hold the drawing surface ON ITS OWN, so a caller who only wants a
11
+ // picture imports one name and reads one type list rather than the library's
12
+ // ninety-seven.
13
+ //
14
+ // It also surfaces four types the root index never exported: Theme and
15
+ // ThemeColors (which an `opts.theme` caller had to spell out by hand) and
16
+ // TrackName / TrackData (the same for `opts.tracks`). Reachable through the
17
+ // signature, nameable nowhere — which is the drift this entry exists to stop.
18
+ //
19
+ // ChantTabulaRow and Score ride along because they are the OTHER HALF of the
20
+ // geometry contract: geometry[i] and tabula[i] are the same note, and a caller
21
+ // holding one without the other cannot use either.
22
+ export { inscriptio } from "./engines/score/inscriptio.js";
23
+ //# sourceMappingURL=inscriptio.js.map
@@ -0,0 +1,6 @@
1
+ export type { Score, ScoreOpts, PondusInput, PondusOpts, AccentusInput, AccentusOpts, Cadence, CadenceTarget, CadenceApproach, Modulation, } from "./engines/score/api.js";
2
+ export type { Note, Performance, Phrase, Syllable, LyricRun, RestEvent, ParseError, ArsisThesis, RhythmicType, CompoundBeat, } from "./engines/score/types.js";
3
+ export type { ChantTabulaRow } from "./engines/score/tabula.js";
4
+ export type { Metrics, RhythmicProfile, NoteRange, CadenceDistribution, } from "./engines/score/metrics.js";
5
+ export type { Imprint, Attractor, VowelAttractor, ModalAffinity, } from "./engines/imprint.js";
6
+ //# sourceMappingURL=score.d.ts.map
package/dist/score.js ADDED
@@ -0,0 +1,20 @@
1
+ // ---------------------------------------------------------------------------
2
+ // tonus/score — what a Score is made of
3
+ // ---------------------------------------------------------------------------
4
+ // `notatio` stays on the root namespace and still returns a Score, so the
5
+ // common case — parse a chant, read its phrases — needs nothing from here. This
6
+ // entry holds the STRUCTURE: the note, the syllable, the phrase, the rhythmic
7
+ // vocabulary, the cadence and modulation records, and the measurement sub-
8
+ // objects a Metrics is built from.
9
+ //
10
+ // They came off the root index because they are not answers, they are the
11
+ // grain of one answer. Ten of the ninety-seven names it carried were reachable
12
+ // only by holding a Score already, which meant a reader scanning the index for
13
+ // what tonus DOES had to step over the anatomy of one return value to find the
14
+ // next verb. That is the density, and this is where it goes.
15
+ //
16
+ // Nothing is hidden by the move: every name below is exported here, and the
17
+ // types a verb hands back — Score, Cadence, Modulation, Metrics — stay on the
18
+ // root as well, because the root's own signatures name them.
19
+ export {};
20
+ //# sourceMappingURL=score.js.map
@@ -108,7 +108,7 @@ The feast returned **carries the view** (`feast.before`), and every chant
108
108
  verb reads it back: `proprium`, `ordinarium`, and `officium`
109
109
  serve only chants attested by the same year, without being told the year
110
110
  twice. One `before` at the calendar door views the whole day. The chant side
111
- — what "attested" means, and what a slot the view excludes does — is in
111
+ (what "attested" means, and what a slot the view excludes does) is in
112
112
  [chant.md](chant.md#the-repertoire-as-of-a-date--the-era-view).
113
113
 
114
114
  ```ts
@@ -142,12 +142,12 @@ interface Feast {
142
142
  }
143
143
  ```
144
144
 
145
- The `masses` list is derived from the Kyriale's own printed rubric — one
145
+ The `masses` list is derived from the Kyriale's own printed rubric, one
146
146
  category per mass, by RANK: "In Paschal Time", "For feasts of the I class",
147
147
  "For Sundays throughout the Year", "For ferias". A day resolves to exactly
148
148
  one rubric (a BVM feast is "of the Blessed Virgin" even in Paschaltide),
149
149
  and the masses carrying that rubric are the masses it may sing, in the
150
- book's own numbering — where a rubric names several (II class 1–5), that
150
+ book's own numbering. Where a rubric names several (II class 1–5), that
151
151
  numbering is the book's invitation to choose, and `ordinarium` rotates
152
152
  among them by year. The book's per-mass nicknames (_Orbis factor_ for
153
153
  Sundays, and so on) record customary use, which disagrees with the rubric for 9
@@ -211,7 +211,7 @@ derived from the date; overflow entries, such as the Epiphany weeks
211
211
  resumed before Septuagesima, take the season of the day they fall on.
212
212
 
213
213
  Season drives real liturgy in the ordinary: in the penitential seasons
214
- (`adv`, `quadp`, `quad`) the Gloria is omitted, and the Ite with it — the
214
+ (`adv`, `quadp`, `quad`) the Gloria is omitted, and the Ite with it. The
215
215
  Benedicamus dismissal appears only where the selected mass carries a
216
216
  setting ([chant.md](chant.md#the-ordinary--ordinarium)).
217
217
 
@@ -20,7 +20,7 @@ is, where it is unusual, and what it is near.
20
20
  tonus.census({ id: "gregobase:1210" });
21
21
  ```
22
22
 
23
- Everything comes back in one call — profile, balance, neighbors:
23
+ Everything comes back in one call: profile, balance, neighbors.
24
24
 
25
25
  ```js
26
26
  {
@@ -56,8 +56,17 @@ interface CensusQuery {
56
56
  }
57
57
  ```
58
58
 
59
- The census covers the **2,187 chants tonus ships** — the same population
60
- `cantus({ id })` addresses, one block per chant. An id with no block throws
59
+ The census covers **7,733 of the 7,840 chants tonus ships**, one block per
60
+ chant. The hundred and seven without one are the *Toni Communes* — office `or`,
61
+ the recitation formulas — and they are left out on purpose: eleven *Benedicamus
62
+ Domino* settings are one gesture, not eleven chants, and censusing them would
63
+ invent a distribution out of a tone. They stay on the shelf; `cantus` finds
64
+ them. They are simply not what typicality is measured against.
65
+
66
+ That population is now the books themselves. Until 2026-08-31 it was the 2,187
67
+ the calendar reached, which meant every typicality figure below was quietly
68
+ measured against one rite's selection rather than against the repertory. An id
69
+ with no block throws
61
70
  rather than returning an empty answer, because a silent nothing reads as "this
62
71
  chant is unlike everything," which is a different claim.
63
72
 
@@ -78,42 +87,42 @@ what they describe:
78
87
  | `textual` | 7 | vowel distribution by sung duration, accent rate, melisma mean |
79
88
 
80
89
  Four more fields ride in the block and are **not** similarity dimensions:
81
- `flags` (a bitfield), `attest` (dating — that is what `before` reads),
90
+ `flags` (a bitfield), `attest` (dating, which is what `before` reads),
82
91
  `extras`, and `reserve`. `by` will not accept them.
83
92
 
84
93
  ## How the measurement works
85
94
 
86
- Every number in a block reads off a single `notatio()` parse — the same parse
87
- `score` gives you — so the census can never disagree with the library about
95
+ Every number in a block reads off a single `notatio()` parse (the same parse
96
+ `score` gives you), so the census can never disagree with the library about
88
97
  what a chant is.
89
98
 
90
99
  Each float is a named measurement, not a learned one: time spent on the
91
100
  subfinal, how often a rising second follows a falling third. When the census
92
101
  calls two chants near, the profile says in what respect.
93
102
 
94
- Most groups are normalized to sum to one, so a group holds a distribution —
95
- where the melody's time goes, not how much of it there is; length is not a
103
+ Most groups are normalized to sum to one, so a group holds a distribution:
104
+ where the melody's time goes, not how much of it there is. Length is not a
96
105
  similarity. The trigram and cadence groups count against dictionaries mined
97
- from the corpus itself — its commonest motifs, its commonest closing gestures,
98
- one bucket for the rest — so the corpus supplies the vocabulary and the chant
106
+ from the corpus itself (its commonest motifs, its commonest closing gestures,
107
+ one bucket for the rest), so the corpus supplies the vocabulary and the chant
99
108
  supplies the usage.
100
109
 
101
- The reference is the mean block over all 2,187 chants, group by group. Because
110
+ The reference is the mean block over all 7,733 chants, group by group. Because
102
111
  blocks are sums of durations and counts, they add: a season's blocks, summed and
103
112
  divided by their count, are the season's mean profile in the same 221 slots.
104
113
 
105
114
  ## Distance is cosine per field group
106
115
 
107
116
  **This is a contract, not an implementation note.** The census answers about
108
- one chant at a time; grouping — "all Communions," "this season," "this
109
- manuscript" — is yours to do. The moment you pool blocks yourself you are
117
+ one chant at a time. Grouping ("all Communions," "this season," "this
118
+ manuscript") is yours to do. The moment you pool blocks yourself you are
110
119
  computing a distance, and if you compute it differently from the rule below
111
120
  your numbers will not agree with `census()`'s. Nothing will error.
112
121
 
113
122
  The rule, in three lines:
114
123
 
115
124
  1. Cosine **per field group**, never over the flat 221.
116
- 2. `by: "all"` is the **equal-weight mean** of the per-group cosines — every
125
+ 2. `by: "all"` is the **equal-weight mean** of the per-group cosines: every
117
126
  dimension one vote, no tunable weights.
118
127
  3. Ties break to the lower id, so the same question always has the same answer.
119
128
 
@@ -122,7 +131,7 @@ sheer magnitude, so a long Tract would neighbor other long chants for being
122
131
  long. Per-group cosine asks about **shape within each dimension**.
123
132
 
124
133
  [`CENSUS_GROUPS`](index.md#the-appendix) gives you the group names and their
125
- field counts, and [`CENSUS_ORDER`](index.md#the-appendix) every censused id —
134
+ field counts, and [`CENSUS_ORDER`](index.md#the-appendix) every censused id,
126
135
  so you can pool a set without guessing at either.
127
136
 
128
137
  ### Reading the numbers
@@ -140,7 +149,7 @@ per-group version spreads from about 0.85 down to 0.65. That compression comes
140
149
  from one wide block outvoting the other eight.
141
150
 
142
151
  **`before` filters before ranking.** It restricts the candidate pool, then
143
- ranks — so `k` stays satisfiable, and a filtered list is *not* a subset of the
152
+ ranks, so `k` stays satisfiable, and a filtered list is *not* a subset of the
144
153
  unfiltered one. Chants that were ranked out by later material rise into it.
145
154
  Typicality is unaffected: it is always measured against the whole shipped
146
155
  corpus (see [Profile and typicality](#profile-and-typicality)).
@@ -201,7 +210,7 @@ const ranked = ids
201
210
 
202
211
  The per-group breakdown is where the answer becomes legible. _Quinque
203
212
  prudentes_ leads on `textual`, `cadenceMedial` and `trigram`, at about 0.99 on
204
- each — it sets its text and turns its phrases the way Communions do — while its
213
+ each (it sets its text and turns its phrases the way Communions do), while its
205
214
  `cadenceFinal` is only about 0.82, so the one thing it does unlike a typical
206
215
  Communion is end. A chant is typical of its genus in some dimensions and not
207
216
  others.
@@ -213,8 +222,8 @@ Each group's `typicality` is its cosine against the corpus mean for that group:
213
222
  "unlike the rest."
214
223
 
215
224
  The two numbers above are a fair illustration. _Ab occultis meis_ is a mode-2
216
- Gradual whose `modal` typicality is about 0.99 — modally it is a typical
217
- mode-2 chant — while its `melodic` typicality is about 0.70, because its
225
+ Gradual whose `modal` typicality is about 0.99, so modally it is a typical
226
+ mode-2 chant. Its `melodic` typicality is about 0.70, because its
218
227
  interval
219
228
  vocabulary is its own. One chant can be conventional in one dimension and
220
229
  distinctive in another, which is the reason the groups are kept apart.
@@ -233,7 +242,7 @@ balance: { distance: 0.091, deviantGroups: ["degreeHist", "melodic"] }
233
242
  the corpus mean, 1 has nothing in common with it.
234
243
 
235
244
  `deviantGroups` names where a chant is unusual **relative to its own mean**,
236
- most deviant first — not against an absolute threshold. The question it answers
245
+ most deviant first, not against an absolute threshold. The question it answers
237
246
  is "given how typical this chant is overall, where does it depart from
238
247
  itself?", which is what makes the answer legible for a chant that is unusual
239
248
  everywhere or nowhere.
@@ -273,12 +282,12 @@ tonus.census({ id: "gregobase:1210", before: 1100 });
273
282
  ```
274
283
 
275
284
  Restricts neighbors to chants a manuscript of the 11th century or earlier
276
- already holds — 1,790 of the 2,186 candidates. This is the same rule as
285
+ already holds, 1,790 of the 2,186 candidates. This is the same rule as
277
286
  [`cantus({ before })`](chant.md#the-repertoire-as-of-a-date--the-era-view),
278
287
  through the same admissibility door: **evidence, not existence**, so a chant
279
288
  with no dated witness is excluded rather than assumed old.
280
289
 
281
- The seed chant itself is never filtered — you asked about it by name.
290
+ The seed chant itself is never filtered, because you asked about it by name.
282
291
 
283
292
  ## What the census is not
284
293