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
package/CHANGELOG.md CHANGED
@@ -2,7 +2,281 @@
2
2
 
3
3
  All notable changes to tonus. Newest first.
4
4
 
5
- ## Unreleased
5
+ ## 0.9.1 — 2026-08-24
6
+
7
+ Moderna reads the pitch it was given: the whole page had been sitting a sixth
8
+ too high, and a transposed chant said nothing about its own accidentals.
9
+
10
+ ### Fixed
11
+
12
+ - **Moderna drew every note a sixth above where it belonged.** `writtenY`
13
+ measured the note against a reference term that had not been normalised the
14
+ same way (`4 * 7` against a note term carrying `+2`), so sounding E4 computed
15
+ as 14 staff steps instead of 0. Every note of every moderna score sat above
16
+ the top line. The port was faithful — the same expression is in the locked
17
+ reference generator, which was fed pitches an octave below today's corpus, so
18
+ two errors cancelled; when the corpus moved to true octaves only one of them
19
+ moved. The clef's octave lift is now stated once, explicitly.
20
+ - **A transposed chromatic rendered as a silent wrong pitch.** `temperamentum`'s
21
+ `transpose` moves what a chant sounds, and moderna reads sounding pitch — but
22
+ the accidental was parsed and discarded, so `Ab3` and `A3` landed on one slot
23
+ with no sign drawn. The page read a semitone off with nothing to say so.
24
+ Spelling now follows `spn`: flats stay flats, sharps stay sharps. Signs
25
+ written in the GABC are unaffected — `computeAccidentals` remains
26
+ authoritative wherever it has an opinion, keyed on the degree a sign alters
27
+ rather than the row that carries it.
28
+ - **Moderna had no ledger lines at all.** A note outside the five rendered as a
29
+ head floating in blank space. One line per staff line the note has passed,
30
+ drawn behind the head: the spaces immediately outside (steps 9 and −1) get
31
+ none, and a line-note and the space above it share one.
32
+ - **`midiToGabc` threw on every B-flat** — the one accidental chant sings, and
33
+ the whole reason `parse.ts` carries a flat state machine. It now spells it
34
+ `ix`, and `gabcToMidi` reads that back, so the two stay inverses. Other
35
+ chromatic pitch classes still throw. Verified over 222 round-trips.
36
+ - **The GABC clef tables disagreed about which clefs exist.** `temper/gabc.ts`
37
+ knew six (`c1`–`c4`, `f3`, `f4`) while `parse.ts` accepted sixteen, so a
38
+ `cb2` the parser reads happily raised "Unknown clef". `CLEFS` is now derived
39
+ rather than hand-listed; all six original entries are reproduced exactly.
40
+
41
+ ### Changed
42
+
43
+ - **A low moderna chant is written an octave up.** One clef throughout — what
44
+ floats is the written octave, the move the hand already makes when it lifts a
45
+ chant onto the gamut's fingers. Chant sounds below this staff (corpus median
46
+ 50–62 against a window of 52–65), and transposing down pushes it further: at
47
+ −4 an unlifted score put 43.6% of all notes under the bottom line, now 9.8%.
48
+ Untransposed moderna output moves for most chants as a result, always toward
49
+ the staff (7.8% off-staff to 5.8%). Whole octaves only, and one lift for the
50
+ whole chant — a page that changed register partway would read as two pieces.
51
+ The limit is honest: an ambitus straddling the window cannot be aligned by
52
+ any multiple of 12, and 59 of 200 measured chants are wider than the staff.
53
+ `notatio`'s `spn` remains the authority on what actually sounds.
54
+ - **Quadrata is untouched by all of this**, by construction: it draws from
55
+ staff position, which transposition does not move. Verified byte-identical.
56
+
57
+ ### Packaging
58
+
59
+ - `.npmignore` removed — it contradicted the `files` allowlist, and two
60
+ mechanisms deciding what ships is one more than can be reasoned about.
61
+ Measured before and after: the tarball is byte-identical at 216 files.
62
+ - `"sideEffects": false` declared, and verified true — nothing in `dist/` runs
63
+ at module scope and no data file is read at import time.
64
+ - The description no longer claims "and performance"; the voice lives in
65
+ tonus-sonus.
66
+
67
+ ## 0.9.0 — 2026-08-19
68
+
69
+ The tracks stop promising a contract they never kept, and the geometry says
70
+ where the ink is.
71
+
72
+ ### Added
73
+
74
+ - **`Syllable.neumes` — the syllable's figures, each classified.** A syllable
75
+ is not a neume; it carries neumes. GABC marks the figures with `!`, `/` and
76
+ `//`, and the parser has always recorded them, but classification read the
77
+ whole syllable, so a melisma of three ordinary figures was named once and
78
+ that name was `compound`: 18,718 of the 27,643 compounds, 67.7% of them.
79
+ `neume` still reads the syllable as one figure and is unchanged; `neumes`
80
+ names them individually. Compound falls from 17.8% to **8.3%**, and the
81
+ torculus count more than doubles (4,521 → 10,607).
82
+
83
+ The salicus reports at syllable scope, and the exception is the point. Its
84
+ rule reads the oriscus on the next-to-last note of an ascent, and 41 of the
85
+ corpus's 255 salici are written across a figure boundary with the oriscus in
86
+ one figure and the summit in the next. Splitting first severed them and the
87
+ count fell to 251, narrowing Cardine's definition by refactoring rather than
88
+ by ruling. Held at 255, with 35 more recovered: figures that are a salicus in
89
+ their own right inside a syllable that is not one.
90
+
91
+ - **Six neume names for figures that restate a pitch.** `classifyShape` had no
92
+ case for the unison beyond a two-note group, so every repercussive figure
93
+ fell to `compound`: 12,675 of them, 6.6% of the corpus. `NeumeShape` gains
94
+ `distropha`, `tristropha`, `tristropha flexa`, `pressus`, `pressus maior`,
95
+ and `scandicus subpunctis` (the mirror of `pes subpunctis`, which was the
96
+ only form named past three intervals). 4,154 groups that read `compound` now
97
+ carry a name, and `compound` falls from 17.8% to 15.9%.
98
+
99
+ The names rest on contour, which is what both emitters already read: quadrata
100
+ breathes strophae apart on the staff position alone, and moderna merges a
101
+ strophic run into one slur. `hasStrophicus` is untouched and still reports
102
+ the GABC marker, which is the narrower fact.
103
+
104
+ This does not reopen the salicus ruling. That one turns on an ornament the
105
+ printed edition may have resolved away, so reading it from the ictus would
106
+ invent what the source withheld; a restated pitch is a contour the source
107
+ states outright.
108
+
109
+ The praepunctis family is absent by choice. Measured over the corpus, the
110
+ long figures are genuine compound melismas (`[up,down,down,up]` 643,
111
+ `[up,down,down,down]` 524) rather than textbook praepunctis forms, and a name
112
+ matching nothing is worse than the gap.
113
+
114
+ - **`NoteGeometry` reports the figure's ink extent**, as `inkLeft` and
115
+ `inkRight` beside the anchor. `x` is an ANCHOR, and what it anchors differs by
116
+ notation: quadrata's square glyphs start at it and run right, so `x` is the
117
+ figure's left edge and a mark drawn anchor-to-anchor sits left of the notes it
118
+ names and stops short of the last one; moderna centres its noteheads on it.
119
+ These are the numbers the in-house tracks have always consumed, now reported
120
+ rather than measured privately. A caller placing a playhead or an overlay no
121
+ longer has to read the drawn glyph back out of the SVG to find the middle of a
122
+ notehead.
123
+
124
+ Quadrata measures the edges from the glyph's bounding box as it places;
125
+ moderna derives them from the anchor, so they straddle it evenly.
126
+
127
+ ### Changed
128
+
129
+ - **The rhythm model names a note by its own figure.** `applyCompoundBeats`
130
+ annotated every note of a melisma with the whole syllable's name, so the two
131
+ conventional overrides (salicus → arsic, doubly-dotted clivis → thetic) could
132
+ not see a clivis inside a syllable that read `compound`. Measured over the
133
+ corpus: 5 phrases of 26,803 change `rhythmicType`, and 75 notes of 400,948
134
+ change arsis/thesis (0.019%). Small because most newly-visible clivises carry
135
+ no double mora, so the thetic rule still does not fire on them.
136
+
137
+ - **The docs no longer offer a downstream track contract.** `score.md` framed
138
+ the geometry as the interface analysis tracks build on, and said a custom
139
+ track downstream does the same. It could not: the tracks consume a private
140
+ surface (the tabula row, the lyric baseline, per-system right edges, the
141
+ scale factor, and band room only an emitter can reserve). The geometry is
142
+ documented for what it is and is used for: locating a note on the drawn page.
143
+ The three tracks stay in the library, and the tracks section is rewritten as
144
+ reference rather than prose.
145
+
146
+ - **The metrics documentation stops dumping six interfaces in a row.** The
147
+ section ended in fifty-odd unbroken lines of TypeScript, its meaning carried
148
+ in trailing comments. The five composite fields are now grouped by the
149
+ question each answers, and the two that were enumerations wearing interfaces
150
+ are tables: `motus` with the semitone ranges its three names mean, and
151
+ `cadenceDistribution` with each divisio's GABC mark and analytic weight —
152
+ the bar-line hierarchy stated once, where `metrics.ts` already cites it.
153
+
154
+ - **Corrected counts in the tracks documentation.** The cadence catalogue holds
155
+ 110 families, not 122; the corpus carries about 26,800 cadences, not 20,500.
156
+ The `rara` share is stated both ways it can be read: about 43% of the
157
+ corpus's cadences, but about a third of the labels a page prints.
158
+
159
+ - **A corpus measurement the library can answer for itself is checked against
160
+ the library.** A count written into prose is a copy, and a copy goes stale on
161
+ the next re-bake with nothing to catch it: the catalogue held 122 families for
162
+ as long as it took someone to notice it held 110, in three places at once.
163
+ `tests/comments.test.mjs` gains the class — the book table, the ledger totals,
164
+ the catalogue's size and population, the censused count, the calendar's size —
165
+ each failure naming the file, the stale figure and the live value. Illustrative
166
+ counts come out of the comments that carried them; calibration provenance
167
+ stays, because a figure recording what a constant was fitted against is the
168
+ evidence for that constant, not decoration.
169
+
170
+ Three figures were already wrong. `chant.md` gave `listings - count` as 580
171
+ extra rows "over the 683 chants printed in more than one book", but 683
172
+ multi-book chants produce 696 extra rows — two measures of different things in
173
+ one sentence, now stated as the rule they illustrate. `score.md` had a cadence
174
+ breakdown failing inclusion–exclusion, and read its 43% as the share that
175
+ joins the catalogue where it is the share that does not.
176
+
177
+ ### Fixed
178
+
179
+ - **The staff was anchored an octave below the gamut.** `guido.ts` fixes Γ
180
+ (gamma ut) at midi 43 and runs two octaves up from it, so within tonus the
181
+ gamut is an absolute frame. The GABC parser anchored the staff an octave under
182
+ it: a c4 chant opened on E2 at 82.5 Hz, and most of the corpus read at or
183
+ below the floor of the medieval system. Measured over 400 chants, 346 sat
184
+ below Γ; none do now. *Ab occultis meis* spans midi 52–65, opening on E3 and
185
+ landing on Elami, Fefaut, Gesolreut.
186
+
187
+ Nothing caught it because the tabula was self-consistent — `spn`, `hz` and
188
+ `octave` all derive from `midi`, so they reported a wrong number faithfully.
189
+ It showed on the Guidonian hand, where a joint is a fixed place: orreliquum
190
+ had grown a per-chant octave lift to haul chants back onto the figure, itself
191
+ replacing an earlier manual control. Two compensations for one constant. A
192
+ downstream consumer carrying such a lift should now remove it.
193
+
194
+ **This moves output data**: every `midi`, `hz`, `spn` and `octave` in the
195
+ tabula shifts by twelve semitones. The census is unaffected — its pitch fields
196
+ are final-relative, and `blocks.bin` rebuilds bit-identical.
197
+
198
+ - **`NoteGeometry.noteIndex` addressed the wrong note.** Both emitters filled
199
+ it from the row's `neumeIndex` (position within the neume FIGURE) where
200
+ `ChantTabulaRow.noteIndex` and `Cadence.notes[*][2]` mean position within the
201
+ SYLLABLE. The documented tuple join therefore misaddressed every syllable
202
+ carrying a second figure: 17 of 159 notes on *Puer natus est*. `noteIndex`
203
+ now carries the syllable position the tabula means, and `neumeIndex` is
204
+ reported alongside it, so the three address one note. A consumer joining by
205
+ array index (the safe join) is unaffected.
206
+
207
+ - **A wrapped cadence closed twice.** A cadence figure crossing a system break
208
+ is re-inked in both, because the claim spans them, but it CLOSES once. Drawn
209
+ per system, the terminal node landed on the earlier fragment's last sample (a
210
+ landing mid-figure) and the label repeated: at width 680 that subject drew 7
211
+ nodes under quadrata and 8 under moderna for 6 confident cadences. The node
212
+ and label now draw only in the system holding the figure's last note.
213
+
214
+ - **The tonarium's mode line admitted claims the rest of the renderer
215
+ refuses.** It gated modulations at confidence 0.4 where the documented floor,
216
+ the cadence path, and the ink doctrine all say nothing below 0.45 draws.
217
+
218
+ - **A flat was drawn on the line of the note it was written before, not the
219
+ line of the note it alters.** Solesmes prints an accidental on the line of
220
+ the pitch it inflects, so a B-flat sign sits on the B line; where the sign is
221
+ printed and which degree it alters are separate facts, and both emitters took
222
+ both from the row carrying the sign. On *Felices sensus* (gregobase:1180) two
223
+ flats governing B-flats were drawn on the G and A lines, where the Liber
224
+ Usualis plate (p. 1637) has them on the B line. Corpus-wide, 3,505 of 4,358
225
+ explicit signs sat on a degree they do not alter. The mark now carries the
226
+ altered degree, and horizontal placement is untouched: the sign still reads
227
+ before the figure it governs.
228
+
229
+ - **A written sign could be dropped.** Repeat-suppression compared the
230
+ sign-carrying row's pitch class against the row immediately before it, which
231
+ measured the wrong pitch (the sign's neighbour, not the degree it alters) and
232
+ applied a weaker rule than the books', which restate once another pitch has
233
+ intervened. *Felices sensus* wrote three flats and printed two: the third
234
+ followed another A, though it opens a new phrase and governs a new B-flat.
235
+ Suppression is now keyed on the altered degree and on nothing else, and it
236
+ holds back only a sign with no music between it and the last mark of that
237
+ degree. A sign whose alteration is never found within its lookahead prints
238
+ rather than borrowing the coordinate of the line it was written on: that line
239
+ is a different fact, and keying both into one map let a found degree and an
240
+ unfound sign's carrying line collide. All three of the fixture's flats print.
241
+
242
+ - **A natural's line was found by the wrong test.** The lookahead for the
243
+ governed degree read `accidental !== 0`, which a natural never satisfies: it
244
+ restores a pitch rather than altering one. Naturals are now read against the
245
+ state they set, so a B-natural sign sits on the B line.
246
+
247
+ ## 0.8.0 — 2026-08-18
248
+
249
+ ### Changed
250
+
251
+ - **`prosody` is renamed `metrics`, because that is what it measures.**
252
+
253
+ - **CI runs the test suite** in place of the retired site deploy.
254
+
255
+ ## 0.7.0 — 2026-08-17
256
+
257
+ The site leaves for its own repo, and the fonts are named rather than bundled.
258
+
259
+ ### Changed
260
+
261
+ - **The site migrated to orreliquum.** The playground, its diagrams and the hand
262
+ figure now live in the site's own repo, which consumes tonus as a dependency;
263
+ this repo keeps the library and `docs/api`.
264
+
265
+ - **Junicode is named, not bundled**, and the font note is stated as a recipe.
266
+ tonus ships no font files: the `fonts` option emits family references, and a
267
+ slot's `embed` carries the caller's own bytes.
268
+
269
+ - **The glyph extractor is JavaScript**, and Bravura lives in the repo.
270
+
271
+ - **The signs get their own documentation page**, and the removed exports leave
272
+ the table.
273
+
274
+ ### Fixed
275
+
276
+ - **A correction to the Guidonian hand data.**
277
+
278
+ - **0.6.1 — the tagged 0.6.0 never reached the registry.** A republish under a
279
+ new patch version, the registry having lagged the git tag.
6
280
 
7
281
  ## 0.6.0 — 2026-08-16
8
282
 
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
+ **[Interactive demo](https://orreliquum.com/)**
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
@@ -72,8 +72,8 @@ its final, dominant, ambitus, and ethos, with its psalm tones.
72
72
  **The calendar** is Tridentine, resolved against Easter by
73
73
  the Julian or the Gregorian computus.
74
74
 
75
- **The repertoire** is 2,187 chants across ten books nine Solesmes editions and
76
- the _Nocturnale Romanum_ night office in
75
+ **The repertoire** is 2,187 chants across ten books, nine Solesmes editions and
76
+ the _Nocturnale Romanum_ night office in
77
77
  [GABC](https://gregorio-project.github.io/) notation: Mass propers, the Kyriale,
78
78
  the Office hours, the psalter, and the Matins nocturns.
79
79
 
@@ -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("");
@@ -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