tonus 0.7.0 → 0.9.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 (35) hide show
  1. package/CHANGELOG.md +213 -1
  2. package/README.md +1 -1
  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/api.d.ts +3 -3
  7. package/dist/engines/score/api.js +3 -3
  8. package/dist/engines/score/cadence.d.ts +10 -4
  9. package/dist/engines/score/cadence.js +10 -4
  10. package/dist/engines/score/emitters/accidentals.d.ts +9 -0
  11. package/dist/engines/score/emitters/accidentals.js +76 -13
  12. package/dist/engines/score/emitters/atramentum.js +1 -1
  13. package/dist/engines/score/emitters/breaking.d.ts +2 -2
  14. package/dist/engines/score/emitters/breaking.js +2 -2
  15. package/dist/engines/score/emitters/moderna.js +16 -6
  16. package/dist/engines/score/emitters/svg.d.ts +16 -0
  17. package/dist/engines/score/emitters/svg.js +19 -12
  18. package/dist/engines/score/emitters/tracks.js +29 -10
  19. package/dist/engines/score/ir.js +24 -4
  20. package/dist/engines/score/{prosody.d.ts → metrics.d.ts} +3 -3
  21. package/dist/engines/score/{prosody.js → metrics.js} +2 -2
  22. package/dist/engines/score/neume.d.ts +19 -0
  23. package/dist/engines/score/neume.js +35 -0
  24. package/dist/engines/score/parse.js +4 -1
  25. package/dist/engines/score/phrasing.js +1 -1
  26. package/dist/engines/score/types.d.ts +6 -0
  27. package/dist/engines/temper/data/guido.js +4 -2
  28. package/dist/engines/temper/neume.d.ts +1 -1
  29. package/dist/engines/temper/neume.js +24 -3
  30. package/dist/index.d.ts +2 -2
  31. package/docs/api/chant.md +7 -7
  32. package/docs/api/index.md +2 -2
  33. package/docs/api/score.md +159 -106
  34. package/docs/api/tuning.md +17 -9
  35. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -2,7 +2,219 @@
2
2
 
3
3
  All notable changes to tonus. Newest first.
4
4
 
5
- ## Unreleased
5
+ ## 0.9.0 — 2026-08-19
6
+
7
+ The tracks stop promising a contract they never kept, and the geometry says
8
+ where the ink is.
9
+
10
+ ### Added
11
+
12
+ - **`Syllable.neumes` — the syllable's figures, each classified.** A syllable
13
+ is not a neume; it carries neumes. GABC marks the figures with `!`, `/` and
14
+ `//`, and the parser has always recorded them, but classification read the
15
+ whole syllable, so a melisma of three ordinary figures was named once and
16
+ that name was `compound`: 18,718 of the 27,643 compounds, 67.7% of them.
17
+ `neume` still reads the syllable as one figure and is unchanged; `neumes`
18
+ names them individually. Compound falls from 17.8% to **8.3%**, and the
19
+ torculus count more than doubles (4,521 → 10,607).
20
+
21
+ The salicus reports at syllable scope, and the exception is the point. Its
22
+ rule reads the oriscus on the next-to-last note of an ascent, and 41 of the
23
+ corpus's 255 salici are written across a figure boundary with the oriscus in
24
+ one figure and the summit in the next. Splitting first severed them and the
25
+ count fell to 251, narrowing Cardine's definition by refactoring rather than
26
+ by ruling. Held at 255, with 35 more recovered: figures that are a salicus in
27
+ their own right inside a syllable that is not one.
28
+
29
+ - **Six neume names for figures that restate a pitch.** `classifyShape` had no
30
+ case for the unison beyond a two-note group, so every repercussive figure
31
+ fell to `compound`: 12,675 of them, 6.6% of the corpus. `NeumeShape` gains
32
+ `distropha`, `tristropha`, `tristropha flexa`, `pressus`, `pressus maior`,
33
+ and `scandicus subpunctis` (the mirror of `pes subpunctis`, which was the
34
+ only form named past three intervals). 4,154 groups that read `compound` now
35
+ carry a name, and `compound` falls from 17.8% to 15.9%.
36
+
37
+ The names rest on contour, which is what both emitters already read: quadrata
38
+ breathes strophae apart on the staff position alone, and moderna merges a
39
+ strophic run into one slur. `hasStrophicus` is untouched and still reports
40
+ the GABC marker, which is the narrower fact.
41
+
42
+ This does not reopen the salicus ruling. That one turns on an ornament the
43
+ printed edition may have resolved away, so reading it from the ictus would
44
+ invent what the source withheld; a restated pitch is a contour the source
45
+ states outright.
46
+
47
+ The praepunctis family is absent by choice. Measured over the corpus, the
48
+ long figures are genuine compound melismas (`[up,down,down,up]` 643,
49
+ `[up,down,down,down]` 524) rather than textbook praepunctis forms, and a name
50
+ matching nothing is worse than the gap.
51
+
52
+ - **`NoteGeometry` reports the figure's ink extent**, as `inkLeft` and
53
+ `inkRight` beside the anchor. `x` is an ANCHOR, and what it anchors differs by
54
+ notation: quadrata's square glyphs start at it and run right, so `x` is the
55
+ figure's left edge and a mark drawn anchor-to-anchor sits left of the notes it
56
+ names and stops short of the last one; moderna centres its noteheads on it.
57
+ These are the numbers the in-house tracks have always consumed, now reported
58
+ rather than measured privately. A caller placing a playhead or an overlay no
59
+ longer has to read the drawn glyph back out of the SVG to find the middle of a
60
+ notehead.
61
+
62
+ Quadrata measures the edges from the glyph's bounding box as it places;
63
+ moderna derives them from the anchor, so they straddle it evenly.
64
+
65
+ ### Changed
66
+
67
+ - **The rhythm model names a note by its own figure.** `applyCompoundBeats`
68
+ annotated every note of a melisma with the whole syllable's name, so the two
69
+ conventional overrides (salicus → arsic, doubly-dotted clivis → thetic) could
70
+ not see a clivis inside a syllable that read `compound`. Measured over the
71
+ corpus: 5 phrases of 26,803 change `rhythmicType`, and 75 notes of 400,948
72
+ change arsis/thesis (0.019%). Small because most newly-visible clivises carry
73
+ no double mora, so the thetic rule still does not fire on them.
74
+
75
+ - **The docs no longer offer a downstream track contract.** `score.md` framed
76
+ the geometry as the interface analysis tracks build on, and said a custom
77
+ track downstream does the same. It could not: the tracks consume a private
78
+ surface (the tabula row, the lyric baseline, per-system right edges, the
79
+ scale factor, and band room only an emitter can reserve). The geometry is
80
+ documented for what it is and is used for: locating a note on the drawn page.
81
+ The three tracks stay in the library, and the tracks section is rewritten as
82
+ reference rather than prose.
83
+
84
+ - **The metrics documentation stops dumping six interfaces in a row.** The
85
+ section ended in fifty-odd unbroken lines of TypeScript, its meaning carried
86
+ in trailing comments. The five composite fields are now grouped by the
87
+ question each answers, and the two that were enumerations wearing interfaces
88
+ are tables: `motus` with the semitone ranges its three names mean, and
89
+ `cadenceDistribution` with each divisio's GABC mark and analytic weight —
90
+ the bar-line hierarchy stated once, where `metrics.ts` already cites it.
91
+
92
+ - **Corrected counts in the tracks documentation.** The cadence catalogue holds
93
+ 110 families, not 122; the corpus carries about 26,800 cadences, not 20,500.
94
+ The `rara` share is stated both ways it can be read: about 43% of the
95
+ corpus's cadences, but about a third of the labels a page prints.
96
+
97
+ - **A corpus measurement the library can answer for itself is checked against
98
+ the library.** A count written into prose is a copy, and a copy goes stale on
99
+ the next re-bake with nothing to catch it: the catalogue held 122 families for
100
+ as long as it took someone to notice it held 110, in three places at once.
101
+ `tests/comments.test.mjs` gains the class — the book table, the ledger totals,
102
+ the catalogue's size and population, the censused count, the calendar's size —
103
+ each failure naming the file, the stale figure and the live value. Illustrative
104
+ counts come out of the comments that carried them; calibration provenance
105
+ stays, because a figure recording what a constant was fitted against is the
106
+ evidence for that constant, not decoration.
107
+
108
+ Three figures were already wrong. `chant.md` gave `listings - count` as 580
109
+ extra rows "over the 683 chants printed in more than one book", but 683
110
+ multi-book chants produce 696 extra rows — two measures of different things in
111
+ one sentence, now stated as the rule they illustrate. `score.md` had a cadence
112
+ breakdown failing inclusion–exclusion, and read its 43% as the share that
113
+ joins the catalogue where it is the share that does not.
114
+
115
+ ### Fixed
116
+
117
+ - **The staff was anchored an octave below the gamut.** `guido.ts` fixes Γ
118
+ (gamma ut) at midi 43 and runs two octaves up from it, so within tonus the
119
+ gamut is an absolute frame. The GABC parser anchored the staff an octave under
120
+ it: a c4 chant opened on E2 at 82.5 Hz, and most of the corpus read at or
121
+ below the floor of the medieval system. Measured over 400 chants, 346 sat
122
+ below Γ; none do now. *Ab occultis meis* spans midi 52–65, opening on E3 and
123
+ landing on Elami, Fefaut, Gesolreut.
124
+
125
+ Nothing caught it because the tabula was self-consistent — `spn`, `hz` and
126
+ `octave` all derive from `midi`, so they reported a wrong number faithfully.
127
+ It showed on the Guidonian hand, where a joint is a fixed place: orreliquum
128
+ had grown a per-chant octave lift to haul chants back onto the figure, itself
129
+ replacing an earlier manual control. Two compensations for one constant. A
130
+ downstream consumer carrying such a lift should now remove it.
131
+
132
+ **This moves output data**: every `midi`, `hz`, `spn` and `octave` in the
133
+ tabula shifts by twelve semitones. The census is unaffected — its pitch fields
134
+ are final-relative, and `blocks.bin` rebuilds bit-identical.
135
+
136
+ - **`NoteGeometry.noteIndex` addressed the wrong note.** Both emitters filled
137
+ it from the row's `neumeIndex` (position within the neume FIGURE) where
138
+ `ChantTabulaRow.noteIndex` and `Cadence.notes[*][2]` mean position within the
139
+ SYLLABLE. The documented tuple join therefore misaddressed every syllable
140
+ carrying a second figure: 17 of 159 notes on *Puer natus est*. `noteIndex`
141
+ now carries the syllable position the tabula means, and `neumeIndex` is
142
+ reported alongside it, so the three address one note. A consumer joining by
143
+ array index (the safe join) is unaffected.
144
+
145
+ - **A wrapped cadence closed twice.** A cadence figure crossing a system break
146
+ is re-inked in both, because the claim spans them, but it CLOSES once. Drawn
147
+ per system, the terminal node landed on the earlier fragment's last sample (a
148
+ landing mid-figure) and the label repeated: at width 680 that subject drew 7
149
+ nodes under quadrata and 8 under moderna for 6 confident cadences. The node
150
+ and label now draw only in the system holding the figure's last note.
151
+
152
+ - **The tonarium's mode line admitted claims the rest of the renderer
153
+ refuses.** It gated modulations at confidence 0.4 where the documented floor,
154
+ the cadence path, and the ink doctrine all say nothing below 0.45 draws.
155
+
156
+ - **A flat was drawn on the line of the note it was written before, not the
157
+ line of the note it alters.** Solesmes prints an accidental on the line of
158
+ the pitch it inflects, so a B-flat sign sits on the B line; where the sign is
159
+ printed and which degree it alters are separate facts, and both emitters took
160
+ both from the row carrying the sign. On *Felices sensus* (gregobase:1180) two
161
+ flats governing B-flats were drawn on the G and A lines, where the Liber
162
+ Usualis plate (p. 1637) has them on the B line. Corpus-wide, 3,505 of 4,358
163
+ explicit signs sat on a degree they do not alter. The mark now carries the
164
+ altered degree, and horizontal placement is untouched: the sign still reads
165
+ before the figure it governs.
166
+
167
+ - **A written sign could be dropped.** Repeat-suppression compared the
168
+ sign-carrying row's pitch class against the row immediately before it, which
169
+ measured the wrong pitch (the sign's neighbour, not the degree it alters) and
170
+ applied a weaker rule than the books', which restate once another pitch has
171
+ intervened. *Felices sensus* wrote three flats and printed two: the third
172
+ followed another A, though it opens a new phrase and governs a new B-flat.
173
+ Suppression is now keyed on the altered degree and on nothing else, and it
174
+ holds back only a sign with no music between it and the last mark of that
175
+ degree. A sign whose alteration is never found within its lookahead prints
176
+ rather than borrowing the coordinate of the line it was written on: that line
177
+ is a different fact, and keying both into one map let a found degree and an
178
+ unfound sign's carrying line collide. All three of the fixture's flats print.
179
+
180
+ - **A natural's line was found by the wrong test.** The lookahead for the
181
+ governed degree read `accidental !== 0`, which a natural never satisfies: it
182
+ restores a pitch rather than altering one. Naturals are now read against the
183
+ state they set, so a B-natural sign sits on the B line.
184
+
185
+ ## 0.8.0 — 2026-08-18
186
+
187
+ ### Changed
188
+
189
+ - **`prosody` is renamed `metrics`, because that is what it measures.**
190
+
191
+ - **CI runs the test suite** in place of the retired site deploy.
192
+
193
+ ## 0.7.0 — 2026-08-17
194
+
195
+ The site leaves for its own repo, and the fonts are named rather than bundled.
196
+
197
+ ### Changed
198
+
199
+ - **The site migrated to orreliquum.** The playground, its diagrams and the hand
200
+ figure now live in the site's own repo, which consumes tonus as a dependency;
201
+ this repo keeps the library and `docs/api`.
202
+
203
+ - **Junicode is named, not bundled**, and the font note is stated as a recipe.
204
+ tonus ships no font files: the `fonts` option emits family references, and a
205
+ slot's `embed` carries the caller's own bytes.
206
+
207
+ - **The glyph extractor is JavaScript**, and Bravura lives in the repo.
208
+
209
+ - **The signs get their own documentation page**, and the removed exports leave
210
+ the table.
211
+
212
+ ### Fixed
213
+
214
+ - **A correction to the Guidonian hand data.**
215
+
216
+ - **0.6.1 — the tagged 0.6.0 never reached the registry.** A republish under a
217
+ new patch version, the registry having lagged the git tag.
6
218
 
7
219
  ## 0.6.0 — 2026-08-16
8
220
 
package/README.md CHANGED
@@ -8,7 +8,7 @@ belong to which day and returns analysis and notation, and calculates the
8
8
  _music of the spheres_ proposed by Pythagoras, Boethius, and more as real
9
9
  intervals.
10
10
 
11
- **[Interactive demo →](https://jeffreypierce.github.io/tonus/)**
11
+ **[Orreliquum the library at work →](https://jeffreypierce.github.io/orreliquum/)**
12
12
 
13
13
  Everything is computed locally and deterministically: the same question always
14
14
  returns the same answer, from data that ships inside the package, with no network
@@ -8,14 +8,14 @@
8
8
  // ReferenceError whenever ordinary.js loaded first).
9
9
  //
10
10
  // ── A FILTER THAT LEFT AND CAME BACK ───────────────────────────────────────
11
- // This filter was once retired because its data had a genre-shaped hole: 31%
12
- // of the corpus undated, 93% of RESPONSORIES among them, so `before: 1098`
13
- // deleted the Hartker repertory from Epiphany Matins — wrong about the
14
- // liturgy on the exact question the library exists to answer. The rule was
15
- // never the problem; the coverage was. The corpus side closed it (crosswalk
16
- // gap-fill + matcher v2: composite respond+verse, the incipit-entry rule,
17
- // containment, genre disambiguation — all gold-gated): 85% of shipped records
18
- // now carry a dated witness and responsories are 92% DATED. What remains
11
+ // This filter was once retired because its data had a genre-shaped hole: a
12
+ // third of the corpus undated, and RESPONSORIES almost all of it, so
13
+ // `before: 1098` deleted the Hartker repertory from Epiphany Matins — wrong
14
+ // about the liturgy on the exact question the library exists to answer. The
15
+ // rule was never the problem; the coverage was. The corpus side closed it
16
+ // (crosswalk gap-fill + matcher v2: composite respond+verse, the incipit-entry
17
+ // rule, containment, genre disambiguation — all gold-gated): the great majority
18
+ // of shipped records now carry a dated witness, responsories included. What remains
19
19
  // undated is deliberate (psalter, formulas) or editorial territory (hymns,
20
20
  // modern propers) — not a genre bias. So the evidence law stands: an undated
21
21
  // chant is EXCLUDED under any cutoff. Silence is not evidence.
@@ -97,10 +97,10 @@ function primeForFeast(feast) {
97
97
  *
98
98
  * A monastic weekday in the temporale mostly has no proper antiphons — the
99
99
  * office-monastic table has the day but leaves antLaudes/antVespera/antMatutinum
100
- * empty (292 of its 409 entries), because those antiphons live in the psalter,
100
+ * empty in most of its entries, because those antiphons live in the psalter,
101
101
  * not in the propers. Without a fallback the hour silently returns a partial
102
- * ordo, which is why Lauds resolved on 106 of 360 days and essentially never in
103
- * the temporale.
102
+ * ordo, which is why Lauds resolved on well under a third of the year's days
103
+ * and essentially never in the temporale.
104
104
  *
105
105
  * ── WHY THE COMMUNE COMES BEFORE THE FERIAL CYCLE ───────────────────────────
106
106
  * A saint's day is not a feria. When the rubrics give a saint no proper
@@ -50,7 +50,13 @@ function buildHalf(syllables, tenor, cadenceNotes, prefixNotes) {
50
50
  }
51
51
  else {
52
52
  const note = noteAssignment[noteIdx++] ?? tenor;
53
- tokens.push(`(${note})${syl}`);
53
+ // TEXT FIRST, then its notes — `syl(note)`, which is what GABC means and
54
+ // what every corpus chant encodes. Emitted the other way round, each
55
+ // whitespace-separated word opened with an empty-text syllable that took
56
+ // syllableIndex 0, so the word's real first syllable never scored
57
+ // wordStart and the renderer hyphenated between every pair: a whole verse
58
+ // set as a single word. The word-aware metrics read the same lie.
59
+ tokens.push(`${syl}(${note})`);
54
60
  }
55
61
  }
56
62
  return tokens.join("");
@@ -1,5 +1,5 @@
1
1
  import { type Imprint } from "../imprint.js";
2
- import { type Prosody } from "./prosody.js";
2
+ import { type Metrics } from "./metrics.js";
3
3
  import { type Cadence } from "./cadence.js";
4
4
  import { type Modulation } from "./modulation.js";
5
5
  import { type ChantTabulaRow } from "./tabula.js";
@@ -28,7 +28,7 @@ export interface Score {
28
28
  phrases: IRPhrase[];
29
29
  errors: ParseError[];
30
30
  tabula: ChantTabulaRow[];
31
- prosody: Prosody;
31
+ metrics: Metrics;
32
32
  /** Mode-specific cadence at each phrase-ending divisio. */
33
33
  cadences: Cadence[];
34
34
  /** Passages where the tonal centre leans away from the home mode. */
@@ -38,7 +38,7 @@ export interface Score {
38
38
  /**
39
39
  * Score builder (`tonus.notatio`). Parses a chant's GABC into a musical
40
40
  * IR — phrases, syllables, notes with tuned pitches, arsis/thesis
41
- * rhythm, prosody, imprint, and a tabula. Options: a temperamentum
41
+ * rhythm, metrics, imprint, and a tabula. Options: a temperamentum
42
42
  * (tuning), a pondus (articulation weight, style name or opts), and an
43
43
  * accentus (phrasing, style name or opts).
44
44
  * @throws Error on invalid Chant input or unparseable GABC.
@@ -6,7 +6,7 @@ import { buildIR } from "./ir.js";
6
6
  import { buildRatios } from "../temper/scale.js";
7
7
  import { computeMeta } from "./meta.js";
8
8
  import { computeImprint } from "../imprint.js";
9
- import { computeProsody } from "./prosody.js";
9
+ import { computeMetrics } from "./metrics.js";
10
10
  import { detectCadences } from "./cadence.js";
11
11
  import { detectModulations } from "./modulation.js";
12
12
  import { computeTabula } from "./tabula.js";
@@ -33,7 +33,7 @@ function resolveAccentus(input) {
33
33
  /**
34
34
  * Score builder (`tonus.notatio`). Parses a chant's GABC into a musical
35
35
  * IR — phrases, syllables, notes with tuned pitches, arsis/thesis
36
- * rhythm, prosody, imprint, and a tabula. Options: a temperamentum
36
+ * rhythm, metrics, imprint, and a tabula. Options: a temperamentum
37
37
  * (tuning), a pondus (articulation weight, style name or opts), and an
38
38
  * accentus (phrasing, style name or opts).
39
39
  * @throws Error on invalid Chant input or unparseable GABC.
@@ -98,7 +98,7 @@ export function buildScore(chant, opts) {
98
98
  phrases: ir.phrases,
99
99
  errors: ir.errors,
100
100
  tabula,
101
- prosody: computeProsody(ir.phrases),
101
+ metrics: computeMetrics(ir.phrases),
102
102
  cadences,
103
103
  modulations,
104
104
  imprint: computeImprint(ir.phrases, scale, {
@@ -59,10 +59,10 @@ export interface Cadence {
59
59
  *
60
60
  * NOT part of the family key — kept because the folded value is still worth
61
61
  * reporting (it is the scale DEGREE, mode-theoretically real). As a key the
62
- * fold made a fifth ABOVE the final share a family with a fourth BELOW:
63
- * measured over 27,985 phrase ends, 3,499 landed on @-5, of which 2,427 were
64
- * really +7 and 1,072 really -5. Two opposite gestures, one key. Arrival in
65
- * the key is therefore the SIGNED offset; see cadenceKeys().
62
+ * fold made a fifth ABOVE the final share a family with a fourth BELOW — two
63
+ * opposite gestures under one key. The measurement behind that ruling is
64
+ * stated once, in `data/cadentiae.ts`'s header, beside the table it governs.
65
+ * Arrival in the key is therefore the SIGNED offset; see cadenceKeys().
66
66
  */
67
67
  export declare function reduceArrival(semitones: number): number;
68
68
  /** One phrase-end event: the family key, and whether it closes the chant. */
@@ -91,6 +91,12 @@ export interface CadenceKeyEvent {
91
91
  * copies is what "no second parser, no drift" forbids; hence this one shared
92
92
  * function.
93
93
  *
94
+ * That 27,985 is the population of the run that PROVED the agreement, and it
95
+ * does not match `CADENTIAE_POPULATION.ends`: the table was rebaked afterwards
96
+ * against the sung corpus. Two measurements of different populations, not a
97
+ * contradiction — the figure here is fixed to its proof and does not track the
98
+ * table.
99
+ *
94
100
  * Takes the FLAT shape, because that is what the census and the miner have; the
95
101
  * engine's own detection flattens into it. A phrase end is a `phraseIndex`
96
102
  * transition or the last row.
@@ -134,10 +134,10 @@ const TAIL = 4;
134
134
  *
135
135
  * NOT part of the family key — kept because the folded value is still worth
136
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().
137
+ * fold made a fifth ABOVE the final share a family with a fourth BELOW — two
138
+ * opposite gestures under one key. The measurement behind that ruling is
139
+ * stated once, in `data/cadentiae.ts`'s header, beside the table it governs.
140
+ * Arrival in the key is therefore the SIGNED offset; see cadenceKeys().
141
141
  */
142
142
  export function reduceArrival(semitones) {
143
143
  let a = semitones % 12;
@@ -158,6 +158,12 @@ export function reduceArrival(semitones) {
158
158
  * copies is what "no second parser, no drift" forbids; hence this one shared
159
159
  * function.
160
160
  *
161
+ * That 27,985 is the population of the run that PROVED the agreement, and it
162
+ * does not match `CADENTIAE_POPULATION.ends`: the table was rebaked afterwards
163
+ * against the sung corpus. Two measurements of different populations, not a
164
+ * contradiction — the figure here is fixed to its proof and does not track the
165
+ * table.
166
+ *
161
167
  * Takes the FLAT shape, because that is what the census and the miner have; the
162
168
  * engine's own detection flattens into it. A phrase end is a `phraseIndex`
163
169
  * transition or the last row.
@@ -9,6 +9,15 @@ export interface AccidentalMark {
9
9
  glyph?: string;
10
10
  /** Text label (kind "cents") — e.g. "−3.9". */
11
11
  label?: string;
12
+ /** The DEGREE THE SIGN ALTERS (kind "glyph", standard mode), as the two
13
+ * emitters address a line: `degree` is quadrata's staffPosition, `degreeSpn`
14
+ * is moderna's written pitch. A flat is printed on the line of the pitch it
15
+ * inflects — a B-flat sign sits on the B line — while its horizontal place
16
+ * is before the figure where it takes effect. Those are two facts, and the
17
+ * carrying row supplies only the second. Both absent when the sign alters
18
+ * nothing found ahead. */
19
+ degree?: number;
20
+ degreeSpn?: string;
12
21
  }
13
22
  export type AccidentalGlyphSet = "modern" | "medieval";
14
23
  /**
@@ -8,6 +8,12 @@ const GLYPHS_BY_SET = {
8
8
  modern: { [-1]: "E260", 0: "E261", 1: "E262" },
9
9
  medieval: { [-1]: "E9E0", 0: "E9E1", 1: "E9E3" },
10
10
  };
11
+ // How far ahead a written sign is read for the degree it alters. A flat is
12
+ // written before the figure it governs, not always before the note: on
13
+ // gregobase:1180 one sign precedes its B-flat by four notes within the
14
+ // syllable. Bounded so a sign whose alteration never arrives does not reach
15
+ // across the chant and borrow an unrelated note's line.
16
+ const SCOPE = 12;
11
17
  // HEJI comma glyphs (Extended Helmholtz–Ellis, U+E2C0–). The syntonic-comma
12
18
  // arrows: one comma down / up. Higher-order commas are out of scope for 0.2.
13
19
  const HEJI_COMMA_DOWN = "E2C2"; // one syntonic comma lower
@@ -24,21 +30,78 @@ function fromPythagorean(row) {
24
30
  */
25
31
  export function computeAccidentals(rows, mode, centsBaseline = "pythagorean", glyphSet = "modern") {
26
32
  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)
33
+ // WHERE a sign is printed and WHICH DEGREE it alters are different facts,
34
+ // and the row carrying the sign supplies only the first. In
35
+ // `fe(jx)cit(ih)` the flat is written before the I while it governs the J:
36
+ // horizontally it precedes the figure (so the singer reads it in time),
37
+ // vertically it belongs to the pitch it inflects. Solesmes prints a flat
38
+ // on the LINE OF THE PITCH IT ALTERS — a B-flat sign sits on the B line
39
+ // [biblio: liber-usualis, Rules for Interpretation; the plate for
40
+ // gregobase:1180 at p. 1637 shows both of that chant's flats on the B
41
+ // line, over an A and a G]. Reading `row.accidental` here drew nothing at
42
+ // all in the `fe(jx)` case (the I is unaltered); reading the carrying
43
+ // row's staffPosition drew the flat on whatever line happened to precede
44
+ // — corpus-wide, 80% of signs sat on a degree they do not alter.
45
+ const alteredOf = (from) => {
46
+ // The sign governs the next note in its scope that CARRIES THE STATE it
47
+ // sets. A flat looks for the note it lowers; a natural looks for the note
48
+ // it restores, which is unaltered by definition (`accidental === 0`) and
49
+ // is marked "state" because the sign, not the clef, put it there.
50
+ // Reading only `accidental !== 0` found the flats and missed every
51
+ // natural's B.
52
+ //
53
+ // The scan opens AT the carrying row, which is the common case: a sign
54
+ // written directly before the note it alters is carried by that note.
55
+ // The two facts coincide there; where they part, the scan walks on.
56
+ const sign = rows[from].accidentalSign ?? rows[from].accidental;
57
+ for (let j = from; j < rows.length && j < from + SCOPE; j++) {
58
+ const r = rows[j];
59
+ if (sign === 0 ? r.accidentalSource === "state" : r.accidental === sign)
60
+ return r;
61
+ }
62
+ return undefined;
63
+ };
64
+ // Restate a sign once another pitch has intervened since that DEGREE was
65
+ // last marked. Comparing the carrying row against the row before it was
66
+ // wrong twice over: it measured the wrong pitch (the sign's neighbour,
67
+ // not the degree it alters), and "the immediately-preceding row" is a
68
+ // weaker rule than the books', which restate after an intervening pitch.
69
+ // On gregobase:1180 the third flat was dropped because the note before it
70
+ // was another A — though it opens a new phrase and governs a new B-flat.
71
+ //
72
+ // SUPPRESSION IS KEYED ON THE ALTERED DEGREE, AND ON NOTHING ELSE. A sign
73
+ // whose alteration is never found has no degree to key on, and it is not
74
+ // keyed on the line it happens to be written on: that line is a different
75
+ // coordinate, and the two sharing one map made a found degree and an
76
+ // unfound sign's carrying line collide. Such a sign always prints —
77
+ // rare, and an unexplained sign on the page is the safer failure.
78
+ const lastMarked = new Map();
79
+ return rows.map((row, i) => {
80
+ if (row.accidentalSource !== "explicit")
34
81
  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
82
  const sign = row.accidentalSign ?? row.accidental;
41
- return { kind: "glyph", glyph: GLYPHS_BY_SET[glyphSet][sign] };
83
+ const altered = alteredOf(i);
84
+ const degree = altered?.staffPosition;
85
+ const mark = {
86
+ kind: "glyph", glyph: GLYPHS_BY_SET[glyphSet][sign],
87
+ degree, degreeSpn: altered?.spn,
88
+ };
89
+ if (degree === undefined)
90
+ return mark;
91
+ const since = lastMarked.get(degree);
92
+ lastMarked.set(degree, i);
93
+ // What the books restate after is INTERVENING MUSIC, and the sign that
94
+ // says nothing new is the one with none: the two signs adjacent, the
95
+ // second merely re-marking a degree the first has just marked. Asking
96
+ // instead whether an intervening row LEFT the degree suppressed a real
97
+ // restatement whenever the music between the signs stayed on the altered
98
+ // degree — the commonest case there is, since that degree is what the
99
+ // signs are both about.
100
+ if (since !== undefined &&
101
+ rows.slice(since + 1, i).every((r) => r.accidentalSource === "explicit")) {
102
+ return null;
103
+ }
104
+ return mark;
42
105
  });
43
106
  }
44
107
  if (mode === "heji") {
@@ -24,7 +24,7 @@
24
24
  // draw.
25
25
  //
26
26
  // ─── HOW THIS MEETS THE PAGE ───────────────────────────────────────────────
27
- // The site (orreliquum-next) keeps a system of its own, stated at the top of
27
+ // The site (orreliquum) keeps a system of its own, stated at the top of
28
28
  // its stylesheet. The two are separate and they AGREE BY SHARING NUMBERS,
29
29
  // not by one importing the other:
30
30
  //
@@ -25,8 +25,8 @@ export interface BreakQuery {
25
25
  * Set when the caller has ALREADY consumed `next.lineBreak` itself. Quadrata
26
26
  * honours `z` in its own block (it must repeat the clef and place a custos
27
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.
28
+ * twice — measured, a Graduale chant drew one custos per system where the
29
+ * last system needs none.
30
30
  */
31
31
  forcedHandled?: boolean;
32
32
  }
@@ -52,8 +52,8 @@ export function decideBreak(q) {
52
52
  // be tested on its own: a single figure wider than a whole line (a 36-note
53
53
  // melisma at 420px) can never be rescued by breaking, but the line before it
54
54
  // should still end rather than run on. Folding this into the `need` test
55
- // below let such a syllable extend a line that was already full measured,
56
- // one Graduale render overran by 56px.
55
+ // below let such a syllable extend a line that was already full, and a
56
+ // Graduale render overran its width.
57
57
  if (q.x > q.boundary)
58
58
  return { break: true, reason: "width" };
59
59
  return q.x + q.need > q.boundary
@@ -172,9 +172,9 @@ const HEAD_K = 0.825;
172
172
  *
173
173
  * This briefly mapped inclinatum to a half note, on the reasoning that the
174
174
  * 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.
175
+ * DIAGRAM's private vocabulary with the notation's: an inclinatum is about a
176
+ * tenth of every note in the corpus, so that share of every transcribed chant
177
+ * came out hollow, each one implying a length it does not have.
178
178
  */
179
179
  function notehead(x, y, small, half, gm) {
180
180
  const s = gm.SCALE * (small ? 0.68 : 1.0) * HEAD_K;
@@ -428,8 +428,12 @@ export function toModerna(rows, chant, options = {}) {
428
428
  if (r.quilisma)
429
429
  body.push(quilismaMark(mx, my, gm));
430
430
  const mk = markByRow.get(r);
431
- if (mk?.kind === "glyph")
432
- body.push(accidentalMark(mx, my, mk.glyph, gm));
431
+ if (mk?.kind === "glyph") {
432
+ // Vertically the sign belongs to the pitch it alters, not to the note
433
+ // it was written before; horizontally it stays at this note's column.
434
+ const ay = mk.degreeSpn ? writtenY(mk.degreeSpn, systemY, gm).y : my;
435
+ body.push(accidentalMark(mx, ay, mk.glyph, gm));
436
+ }
433
437
  else if (mk?.kind === "cents") {
434
438
  // Cents labels float in a band above the staff (not glued to the
435
439
  // head) — an analytic overlay, not an engraving mark.
@@ -601,10 +605,16 @@ export function toModerna(rows, chant, options = {}) {
601
605
  phraseIndex: pl.row.phraseIndex,
602
606
  syllableIndex: pl.row.syllableIndex,
603
607
  neumeGroup: pl.row.neumeGroup,
604
- noteIndex: pl.row.neumeIndex,
608
+ noteIndex: pl.row.noteIndex,
609
+ neumeIndex: pl.row.neumeIndex,
605
610
  system: pl.system,
606
611
  x: Number(pl.x.toFixed(2)),
607
612
  y: Number(pl.y.toFixed(2)),
613
+ // Derived, not measured: moderna centres its noteheads on the anchor, so
614
+ // the ink straddles it evenly. The track mapping above derives them the
615
+ // same way, from the same number.
616
+ inkLeft: Number((pl.x - gm.NH_W / 2).toFixed(2)),
617
+ inkRight: Number((pl.x + gm.NH_W / 2).toFixed(2)),
608
618
  systemY: Number(pl.systemY.toFixed(2)),
609
619
  }));
610
620
  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
  }