tonus 0.8.0 → 0.9.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 (34) hide show
  1. package/CHANGELOG.md +275 -1
  2. package/README.md +3 -3
  3. package/dist/engines/chant/attest.js +8 -8
  4. package/dist/engines/chant/hour.js +3 -3
  5. package/dist/engines/chant/intone.js +7 -1
  6. package/dist/engines/score/cadence.d.ts +10 -4
  7. package/dist/engines/score/cadence.js +10 -4
  8. package/dist/engines/score/emitters/accidentals.d.ts +9 -0
  9. package/dist/engines/score/emitters/accidentals.js +76 -13
  10. package/dist/engines/score/emitters/atramentum.js +1 -1
  11. package/dist/engines/score/emitters/breaking.d.ts +2 -2
  12. package/dist/engines/score/emitters/breaking.js +2 -2
  13. package/dist/engines/score/emitters/moderna.js +165 -17
  14. package/dist/engines/score/emitters/svg.d.ts +16 -0
  15. package/dist/engines/score/emitters/svg.js +19 -12
  16. package/dist/engines/score/emitters/tracks.js +29 -10
  17. package/dist/engines/score/ir.js +24 -4
  18. package/dist/engines/score/neume.d.ts +19 -0
  19. package/dist/engines/score/neume.js +35 -0
  20. package/dist/engines/score/parse.js +4 -1
  21. package/dist/engines/score/types.d.ts +6 -0
  22. package/dist/engines/temper/data/guido.js +4 -2
  23. package/dist/engines/temper/gabc.d.ts +12 -0
  24. package/dist/engines/temper/gabc.js +51 -18
  25. package/dist/engines/temper/neume.d.ts +1 -1
  26. package/dist/engines/temper/neume.js +24 -3
  27. package/docs/api/calendar.md +4 -4
  28. package/docs/api/census.md +20 -20
  29. package/docs/api/chant.md +27 -27
  30. package/docs/api/heavens.md +7 -7
  31. package/docs/api/index.md +5 -5
  32. package/docs/api/score.md +191 -138
  33. package/docs/api/tuning.md +31 -23
  34. package/package.json +4 -3
@@ -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
@@ -172,9 +246,9 @@ const HEAD_K = 0.825;
172
246
  *
173
247
  * This briefly mapped inclinatum to a half note, on the reasoning that the
174
248
  * ambitus figure uses that shape to mark a mode's tenor. That confused a
175
- * DIAGRAM's private vocabulary with the notation's: an inclinatum is 10.4% of
176
- * every note in the corpus, so a tenth of every transcribed chant came out
177
- * hollow, each one implying a length it does not have.
249
+ * DIAGRAM's private vocabulary with the notation's: an inclinatum is about a
250
+ * tenth of every note in the corpus, so that share of every transcribed chant
251
+ * came out hollow, each one implying a length it does not have.
178
252
  */
179
253
  function notehead(x, y, small, half, gm) {
180
254
  const s = gm.SCALE * (small ? 0.68 : 1.0) * HEAD_K;
@@ -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,21 +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
- if (mk?.kind === "glyph")
432
- body.push(accidentalMark(mx, my, mk.glyph, gm));
566
+ if (mk?.kind === "glyph") {
567
+ // Vertically the sign belongs to the pitch it alters, not to the note
568
+ // it was written before; horizontally it stays at this note's column.
569
+ const ay = mk.degreeSpn ? writtenY(mk.degreeSpn, systemY, gm, lift).y : my;
570
+ body.push(accidentalMark(mx, ay, mk.glyph, gm));
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));
433
575
  else if (mk?.kind === "cents") {
434
576
  // Cents labels float in a band above the staff (not glued to the
435
577
  // head) — an analytic overlay, not an engraving mark.
@@ -595,16 +737,22 @@ export function toModerna(rows, chant, options = {}) {
595
737
  `width="${W}" height="${height}" class="tonus-chant moderna">${svgTitle}` +
596
738
  lyricEmbed +
597
739
  header.join("") +
598
- staff.join("") + clefSvgs.join("") + body.join("") + slurs.join("") + lyricSvgs.join("") +
740
+ staff.join("") + ledgers.join("") + clefSvgs.join("") + body.join("") + slurs.join("") + lyricSvgs.join("") +
599
741
  `</svg>`;
600
742
  const geometry = placements.map((pl) => ({
601
743
  phraseIndex: pl.row.phraseIndex,
602
744
  syllableIndex: pl.row.syllableIndex,
603
745
  neumeGroup: pl.row.neumeGroup,
604
- noteIndex: pl.row.neumeIndex,
746
+ noteIndex: pl.row.noteIndex,
747
+ neumeIndex: pl.row.neumeIndex,
605
748
  system: pl.system,
606
749
  x: Number(pl.x.toFixed(2)),
607
750
  y: Number(pl.y.toFixed(2)),
751
+ // Derived, not measured: moderna centres its noteheads on the anchor, so
752
+ // the ink straddles it evenly. The track mapping above derives them the
753
+ // same way, from the same number.
754
+ inkLeft: Number((pl.x - gm.NH_W / 2).toFixed(2)),
755
+ inkRight: Number((pl.x + gm.NH_W / 2).toFixed(2)),
608
756
  systemY: Number(pl.systemY.toFixed(2)),
609
757
  }));
610
758
  return { svg, geometry };
@@ -124,13 +124,29 @@ export declare function lyricMarkup(runs: LyricRun[] | undefined, plain: string,
124
124
  export interface NoteGeometry {
125
125
  phraseIndex: number;
126
126
  syllableIndex: number;
127
+ /** 0-based index of the neume figure within the syllable (GABC break markers). */
127
128
  neumeGroup: number;
129
+ /** 0-based position of this note within its SYLLABLE — the same index
130
+ * `ChantTabulaRow.noteIndex` and `Cadence.notes[*][2]` carry, so the three
131
+ * address one note. Both emitters used to fill this from `neumeIndex`, which
132
+ * agrees only on syllables of a single figure: on *Puer natus est* the
133
+ * documented tuple join misaddressed 17 of 159 notes. */
128
134
  noteIndex: number;
135
+ /** 0-based position of this note within its NEUME FIGURE. */
136
+ neumeIndex: number;
129
137
  /** Which system (staff line) the note landed in — 0 when nothing wraps. */
130
138
  system: number;
131
139
  /** Notehead anchor in svg user units. */
132
140
  x: number;
133
141
  y: number;
142
+ /** The figure's measured ink extent. `x` is the ANCHOR — quadrata's square
143
+ * glyphs start there and run right, so a span drawn anchor-to-anchor sits
144
+ * left of the notes it names and stops short of the last one. A mark that
145
+ * spans notes reaches for these instead. Quadrata measures them from the
146
+ * glyph's bbox as it places; moderna centres its heads on the anchor, so
147
+ * they are derived and straddle it evenly. */
148
+ inkLeft: number;
149
+ inkRight: number;
134
150
  /** The system's top offset within the svg — 0 in the first system. */
135
151
  systemY: number;
136
152
  }
@@ -492,7 +492,9 @@ export function toSvg(rows, chant, options = {}) {
492
492
  `fill="${r.noteColor}">${esc(mark.label ?? "")}</text>`);
493
493
  return 0;
494
494
  }
495
- const p = placeGlyph(mark.glyph, atX, yFor(row.staffPosition, L, r), r, "accidental", "", r.noteScale * 0.62);
495
+ // The sign sits on the line of the pitch it alters, which is not this
496
+ // row's own line where the sign was written before the figure it governs.
497
+ const p = placeGlyph(mark.glyph, atX, yFor(mark.degree ?? row.staffPosition, L, r), r, "accidental", "", r.noteScale * 0.62);
496
498
  if (!p)
497
499
  return 0;
498
500
  body.push(p.svg);
@@ -736,9 +738,9 @@ export function toSvg(rows, chant, options = {}) {
736
738
  // GABC's `z` says "start a new line here", and it is not a hint: an
737
739
  // editor who set a chant chose where its lines end, and that choice
738
740
  // carries a reading of the piece a width cannot infer. tonus SKIPPED the
739
- // token at parse 41 Graduale chants carry one and every break was
740
- // being thrown away, which is why the automatic breaks looked arbitrary
741
- // against a printed copy.
741
+ // token at parse, so every break the Graduale chants that carry one had
742
+ // set was being thrown away, which is why the automatic breaks looked
743
+ // arbitrary against a printed copy.
742
744
  //
743
745
  // It wins over the fit test. Where it is absent the layout still decides.
744
746
  if (r.width != null && figure[0].lineBreak && prevSyllable !== -1) {
@@ -831,8 +833,9 @@ export function toSvg(rows, chant, options = {}) {
831
833
  // Break when the NEXT phrase will not fit, rather than once this one has
832
834
  // already overrun. The check was `x > width - padding`, which only fires
833
835
  // AFTER the boundary is crossed — and since a system may break only at a
834
- // divisio, the overrun was a whole phrase wide. Measured over thirty
835
- // graduals, every one of them overran a 900px request, by up to 289px.
836
+ // divisio, the overrun was a whole phrase wide. Measured over a sweep of
837
+ // graduals, every one of them overran the requested width, some by a
838
+ // wide margin.
836
839
  // That is what made a render wider than the column it was drawn for, and
837
840
  // why "sometimes bigger, sometimes smaller" varied by chant: the overrun
838
841
  // depends on where the phrases happen to fall.
@@ -984,9 +987,9 @@ export function toSvg(rows, chant, options = {}) {
984
987
  // above. But it cannot be the only one: quadrata's break test used to live
985
988
  // entirely inside `if (div && phraseEnds)`, so a system could end nowhere
986
989
  // else, and a phrase wider than the line simply ran until its next barline.
987
- // Measured over 120 graduals, a QUARTER of quadrata's lines came out under
988
- // 75% full against 6% in moderna — which breaks between syllables. That gap
989
- // was the asymmetry, not a spacing difference.
990
+ // Measured over a sweep of graduals, a QUARTER of quadrata's lines came out
991
+ // barely more than half full, against a handful in moderna — which breaks
992
+ // between syllables. That gap was the asymmetry, not a spacing difference.
990
993
  //
991
994
  // The books break mid-phrase freely; the unit is the word, never a syllable
992
995
  // mid-word (which would split a lyric) and never mid-neume. So: at a word
@@ -1217,8 +1220,9 @@ export function toSvg(rows, chant, options = {}) {
1217
1220
  // ...and a word carried to the NEXT system takes a hyphen at the line's
1218
1221
  // end, which is what the books set. The gap-centred rule above cannot
1219
1222
  // reach this case — the two syllables have no gap between them, they have
1220
- // a line break — so the hyphen was simply dropped: measured, 351 splits
1221
- // across 165 of 200 graduals rendered with nothing joining the halves.
1223
+ // a line break — so the hyphen was simply dropped: measured, the great
1224
+ // majority of a sweep of graduals rendered a split word with nothing
1225
+ // joining the halves.
1222
1226
  // "Sanc" ended a line and "tus" opened the next, reading as two words.
1223
1227
  const thisRight = ly.cx + estLyricW(ly.text) / 2;
1224
1228
  lyricSvgs.push(lyricText(thisRight + r.lyricSize * 0.42, ly.systemY, "-"));
@@ -1324,10 +1328,13 @@ export function toSvg(rows, chant, options = {}) {
1324
1328
  phraseIndex: pl.row.phraseIndex,
1325
1329
  syllableIndex: pl.row.syllableIndex,
1326
1330
  neumeGroup: pl.row.neumeGroup,
1327
- noteIndex: pl.row.neumeIndex,
1331
+ noteIndex: pl.row.noteIndex,
1332
+ neumeIndex: pl.row.neumeIndex,
1328
1333
  system: pl.system,
1329
1334
  x: Number(pl.x.toFixed(2)),
1330
1335
  y: Number(pl.y.toFixed(2)),
1336
+ inkLeft: Number(pl.inkLeft.toFixed(2)),
1337
+ inkRight: Number(pl.inkRight.toFixed(2)),
1331
1338
  systemY: Number(pl.systemY.toFixed(2)),
1332
1339
  }));
1333
1340
  return { svg, geometry };
@@ -513,7 +513,7 @@ const SHARE_FLOOR = 10;
513
513
  */
514
514
  function cadenceLabel(fam, mode) {
515
515
  // NO CATALOGUE FAMILY IS ITSELF A MEASUREMENT. CADENTIAE holds the families
516
- // above a floor of fifty corpus occurrences — 122 of them — so a close that
516
+ // above a floor of fifty corpus occurrences — 110 of them — so a close that
517
517
  // fails to join is not unknown, it is RARER than anything the catalogue
518
518
  // records. A third of inked cadences land here, and leaving them bare made
519
519
  // the rarest closes look like the ones the analysis had nothing to say
@@ -570,7 +570,7 @@ export function buildTonarium(notes, data, cfg) {
570
570
  const governing = (p) => {
571
571
  let best = null;
572
572
  for (const m of data.modulations) {
573
- if (m.confidence >= 0.4 && m.startPhrase <= p && p <= m.endPhrase &&
573
+ if (m.confidence >= CONF_FLOOR && m.startPhrase <= p && p <= m.endPhrase &&
574
574
  (!best || m.confidence > best.confidence))
575
575
  best = m;
576
576
  }
@@ -579,6 +579,18 @@ export function buildTonarium(notes, data, cfg) {
579
579
  return home != null ? { mode: home, conf: 1, kind: "home" } : null;
580
580
  };
581
581
  const systems = [...new Set(notes.map((n) => n.system))].sort((a, b) => a - b);
582
+ // WHERE EACH CADENCE LANDS. `notes` is in tabula order, so the last note
583
+ // carrying a cadenceRef is that cadence's closing note, and the system it
584
+ // sits in is the only one that may draw the closing dot and the label. A
585
+ // figure that wraps is re-inked in every system it crosses — the claim spans
586
+ // them — but it CLOSES once. Drawn per-system, a wrapped cadence printed its
587
+ // landing dot on the earlier fragment's last sample (a landing mid-figure)
588
+ // and repeated its label, so one close read as two.
589
+ const landingSystem = new Map();
590
+ for (const n of notes) {
591
+ if (n.row.cadenceRef != null)
592
+ landingSystem.set(n.row.cadenceRef, n.system);
593
+ }
582
594
  for (const s of systems) {
583
595
  const sysNotes = notes.filter((n) => n.system === s);
584
596
  const sysY = sysNotes[0].systemY;
@@ -676,17 +688,22 @@ export function buildTonarium(notes, data, cfg) {
676
688
  const samples = (samplesByPhrase.get(fig[0].row.phraseIndex) ?? [])
677
689
  .filter(([px]) => px >= x0 && px <= x1 + 2 * k);
678
690
  // Where the cadence LANDS — the closing dot, which the label centres on.
691
+ // Only the landing system draws it; an earlier fragment re-inks the
692
+ // ribbon and stops there.
693
+ const lands = landingSystem.get(ci) === s;
679
694
  let dot;
680
695
  if (samples.length >= 2) {
681
696
  const d = ribbonPath(samples, vat, vmax, 1);
682
697
  g.push(`<path d="${d}" fill="${INK}" fill-opacity="${(STRATUM.cadence * op).toFixed(2)}"/>`);
683
- const [nx, ny] = samples[samples.length - 1];
684
- dot = nx;
685
- const r = sc(1.8 * k);
686
- g.push(closes
687
- ? `<circle cx="${nx.toFixed(1)}" cy="${ny.toFixed(1)}" r="${r}" fill="${INK}" opacity="${op.toFixed(2)}"/>`
688
- : `<circle cx="${nx.toFixed(1)}" cy="${ny.toFixed(1)}" r="${r}" fill="none" stroke="${INK}" ` +
689
- `stroke-width="${sc(0.9 * k)}" opacity="${op.toFixed(2)}"/>`);
698
+ if (lands) {
699
+ const [nx, ny] = samples[samples.length - 1];
700
+ dot = nx;
701
+ const r = sc(1.8 * k);
702
+ g.push(closes
703
+ ? `<circle cx="${nx.toFixed(1)}" cy="${ny.toFixed(1)}" r="${r}" fill="${INK}" opacity="${op.toFixed(2)}"/>`
704
+ : `<circle cx="${nx.toFixed(1)}" cy="${ny.toFixed(1)}" r="${r}" fill="none" stroke="${INK}" ` +
705
+ `stroke-width="${sc(0.9 * k)}" opacity="${op.toFixed(2)}"/>`);
706
+ }
690
707
  }
691
708
  // The label: how characteristic this close is OF THIS CHANT'S MODE —
692
709
  // the family's in-mode share over its corpus share ("×2.1"). The raw
@@ -696,7 +713,7 @@ export function buildTonarium(notes, data, cfg) {
696
713
  // system's edge it clamps to the margin rather than jumping to the
697
714
  // figure's other side. A light end-ticked bracket ties it to the span.
698
715
  const lab = cadenceLabel(fam, data.mode);
699
- if (lab) {
716
+ if (lab && lands) {
700
717
  // The label sits UNDER THE CLOSING DOT, centred on it. The dot is
701
718
  // where the cadence lands — the one point the measure is about — so
702
719
  // the number belongs beneath it rather than trailing the figure at
@@ -704,6 +721,8 @@ export function buildTonarium(notes, data, cfg) {
704
721
  // figure's span, which the re-inked sparkline above already draws,
705
722
  // and two marks for one extent read as two claims.
706
723
  const estW = lab.length * 5.6 * k; // 9px mono advance, measured
724
+ // With fewer than two samples in the landing slice no dot was drawn;
725
+ // the label centres on the figure's closing ink instead of orphaning.
707
726
  const dotX = dot ?? x1;
708
727
  const left = xL;
709
728
  const right = cfg.rightFor(s) - 2 * k;
@@ -1,7 +1,7 @@
1
1
  import { toPitch } from "../temper/pitch.js";
2
2
  import { toStep } from "../temper/step.js";
3
3
  import { selectVowel } from "../chant/syllabify.js";
4
- import { classifyNeume } from "./neume.js";
4
+ import { classifyNeume, classifyFigures } from "./neume.js";
5
5
  function rawToNote(raw, scale) {
6
6
  const midi = raw.step;
7
7
  // One read of the lyric: the nucleus and the diphthong it belongs to.
@@ -65,14 +65,15 @@ function rawToNote(raw, scale) {
65
65
  const SALICUS_PROLONGATION = 1.3;
66
66
  function makeSyllable(lyric, notes) {
67
67
  const neume = classifyNeume(notes);
68
+ const neumes = classifyFigures(notes);
68
69
  if (neume.type === "salicus" && notes.length >= 2) {
69
70
  const summit = notes[notes.length - 1];
70
71
  summit.performance.duration *= SALICUS_PROLONGATION;
71
72
  }
72
73
  const runs = notes[0]?.context.runs;
73
74
  return runs
74
- ? { lyric, runs, notes, neume, melisma: notes.length }
75
- : { lyric, notes, neume, melisma: notes.length };
75
+ ? { lyric, runs, notes, neume, neumes, melisma: notes.length }
76
+ : { lyric, notes, neume, neumes, melisma: notes.length };
76
77
  }
77
78
  function partitionByIctus(annotated) {
78
79
  const groups = [];
@@ -217,8 +218,27 @@ function applyCompoundBeats(phrases) {
217
218
  for (const phrase of phrases) {
218
219
  const annotated = [];
219
220
  for (const syl of phrase.syllables) {
221
+ // A note is named by ITS OWN figure. Annotating every note of a melisma
222
+ // with the whole syllable's name told the two conventional overrides
223
+ // below (salicus → arsic, doubly-dotted clivis → thetic) that a
224
+ // three-figure syllable was one "compound" neume, so a clivis inside it
225
+ // was invisible to the rule that names it. The salicus is classified at
226
+ // syllable scope and so survives the split — see `classifyFigures`.
227
+ const byGroup = new Map();
228
+ let figure = -1;
229
+ let cursor = -1;
220
230
  for (const note of syl.notes) {
221
- annotated.push({ note, neumeType: syl.neume.type });
231
+ if (note.context.neumeGroup !== figure) {
232
+ figure = note.context.neumeGroup;
233
+ cursor++;
234
+ }
235
+ byGroup.set(note.context.neumeGroup, syl.neumes[cursor]?.type ?? syl.neume.type);
236
+ }
237
+ for (const note of syl.notes) {
238
+ annotated.push({
239
+ note,
240
+ neumeType: byGroup.get(note.context.neumeGroup) ?? syl.neume.type,
241
+ });
222
242
  }
223
243
  }
224
244
  phrase.beats = classifyCompoundBeats(annotated);
@@ -1,3 +1,22 @@
1
1
  import type { Neume, Note } from "./types.js";
2
2
  export declare function classifyNeume(notes: Note[]): Neume;
3
+ /**
4
+ * The syllable's figures, each classified in its own right.
5
+ *
6
+ * GABC marks figure boundaries (`!`, `/`, `//`) and the parser records them as
7
+ * `context.neumeGroup`, but classification read the whole syllable, so a
8
+ * three-figure melisma was named once and the name was almost always
9
+ * "compound" — two of every three compounds the corpus reported. A syllable is
10
+ * not a neume; it carries neumes.
11
+ *
12
+ * THE SALICUS IS CLASSIFIED AT SYLLABLE SCOPE, DELIBERATELY, and the exception
13
+ * is the point rather than an oversight. Its rule reads the oriscus on the
14
+ * next-to-last note of an ascent (see `classifyNeume` above), and 41 of the
15
+ * corpus's 255 salici are written across a figure boundary — the oriscus in
16
+ * one figure, the summit in the next. Splitting first severed them and the
17
+ * count fell to 251, which would have narrowed Cardine's definition by
18
+ * refactoring rather than by ruling. The ascent is the neume; where the
19
+ * scribe broke the figure is a separate fact.
20
+ */
21
+ export declare function classifyFigures(notes: Note[]): Neume[];
3
22
  //# sourceMappingURL=neume.d.ts.map
@@ -56,4 +56,39 @@ export function classifyNeume(notes) {
56
56
  }
57
57
  return { type, intervals, hasQuilisma, hasLiquescent, hasStrophicus };
58
58
  }
59
+ /**
60
+ * The syllable's figures, each classified in its own right.
61
+ *
62
+ * GABC marks figure boundaries (`!`, `/`, `//`) and the parser records them as
63
+ * `context.neumeGroup`, but classification read the whole syllable, so a
64
+ * three-figure melisma was named once and the name was almost always
65
+ * "compound" — two of every three compounds the corpus reported. A syllable is
66
+ * not a neume; it carries neumes.
67
+ *
68
+ * THE SALICUS IS CLASSIFIED AT SYLLABLE SCOPE, DELIBERATELY, and the exception
69
+ * is the point rather than an oversight. Its rule reads the oriscus on the
70
+ * next-to-last note of an ascent (see `classifyNeume` above), and 41 of the
71
+ * corpus's 255 salici are written across a figure boundary — the oriscus in
72
+ * one figure, the summit in the next. Splitting first severed them and the
73
+ * count fell to 251, which would have narrowed Cardine's definition by
74
+ * refactoring rather than by ruling. The ascent is the neume; where the
75
+ * scribe broke the figure is a separate fact.
76
+ */
77
+ export function classifyFigures(notes) {
78
+ if (notes.length === 0)
79
+ return [];
80
+ const whole = classifyNeume(notes);
81
+ if (whole.type === "salicus")
82
+ return [whole];
83
+ const figures = [];
84
+ let group = -1;
85
+ for (const note of notes) {
86
+ if (note.context.neumeGroup !== group) {
87
+ figures.push([]);
88
+ group = note.context.neumeGroup;
89
+ }
90
+ figures[figures.length - 1].push(note);
91
+ }
92
+ return figures.map(classifyNeume);
93
+ }
59
94
  //# sourceMappingURL=neume.js.map
@@ -2,8 +2,11 @@ import { buildArticulation } from "./articulation.js";
2
2
  import { createLyricDecoder } from "./lyric.js";
3
3
  import { detectVowelAccent } from "../chant/syllabify.js";
4
4
  // Constants
5
+ // `oct` anchors the staff onto the gamut, which is an absolute frame: guido.ts
6
+ // fixes Γ at midi 43. At oct 3 a chant read an octave below that anchor, so
7
+ // most of the corpus sat at or under the gamut's floor.
5
8
  const DEFAULT_OPTIONS = {
6
- oct: 3,
9
+ oct: 4,
7
10
  useVowelAccent: true,
8
11
  };
9
12
  // Per-clef diatonic-step offset. A GABC clef names the staff line it sits on
@@ -221,7 +221,13 @@ export interface Syllable {
221
221
  /** Styled lyric spans; present only when GABC markup styled this syllable. */
222
222
  runs?: LyricRun[];
223
223
  notes: Note[];
224
+ /** The syllable read as ONE figure. A syllable carrying several neumes
225
+ * classifies as "compound" here; `neumes` names them individually. */
224
226
  neume: Neume;
227
+ /** The syllable's figures, as GABC groups them (`!`, `/`, `//`), each
228
+ * classified in its own right. One entry for a syllable of one figure —
229
+ * then `neumes[0]` and `neume` agree. */
230
+ neumes: Neume[];
225
231
  /** Notes sung on this syllable — its melisma. 1 = syllabic, >1 = melismatic. */
226
232
  melisma: number;
227
233
  }
@@ -7,8 +7,10 @@
7
7
  // finger to cc before crossing to the middle for dd and ee. Reading it with
8
8
  // cc on the middle and dd on the ring makes the line cross three times where
9
9
  // the hand turns once. The figure that DRAWS the turn lives in the site
10
- // (orreliquum-next), and its LOCUS table must agree with the fingers named
11
- // here; the test that holds the two together lives there beside it. The two b/♮ pairs (58/59, 70/71) share a
10
+ // (orreliquum), and its LOCUS table must agree with the fingers named here;
11
+ // the test that holds the two together lives there beside it.
12
+ //
13
+ // The two b/♮ pairs (58/59, 70/71) share a
12
14
  // locus by design — one joint, two readings (fa in molle, mi in durum), the
13
15
  // tradition, not a duplication to fix. They are one distinction, named for the
14
16
  // two shapes of the letter: b rotundum (the round b) against b quadratum (the
@@ -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;