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.
- package/CHANGELOG.md +275 -1
- package/README.md +3 -3
- package/dist/engines/chant/attest.js +8 -8
- package/dist/engines/chant/hour.js +3 -3
- package/dist/engines/chant/intone.js +7 -1
- package/dist/engines/score/cadence.d.ts +10 -4
- package/dist/engines/score/cadence.js +10 -4
- package/dist/engines/score/emitters/accidentals.d.ts +9 -0
- package/dist/engines/score/emitters/accidentals.js +76 -13
- package/dist/engines/score/emitters/atramentum.js +1 -1
- package/dist/engines/score/emitters/breaking.d.ts +2 -2
- package/dist/engines/score/emitters/breaking.js +2 -2
- package/dist/engines/score/emitters/moderna.js +165 -17
- package/dist/engines/score/emitters/svg.d.ts +16 -0
- package/dist/engines/score/emitters/svg.js +19 -12
- package/dist/engines/score/emitters/tracks.js +29 -10
- package/dist/engines/score/ir.js +24 -4
- package/dist/engines/score/neume.d.ts +19 -0
- package/dist/engines/score/neume.js +35 -0
- package/dist/engines/score/parse.js +4 -1
- package/dist/engines/score/types.d.ts +6 -0
- package/dist/engines/temper/data/guido.js +4 -2
- package/dist/engines/temper/gabc.d.ts +12 -0
- package/dist/engines/temper/gabc.js +51 -18
- package/dist/engines/temper/neume.d.ts +1 -1
- package/dist/engines/temper/neume.js +24 -3
- package/docs/api/calendar.md +4 -4
- package/docs/api/census.md +20 -20
- package/docs/api/chant.md +27 -27
- package/docs/api/heavens.md +7 -7
- package/docs/api/index.md +5 -5
- package/docs/api/score.md +191 -138
- package/docs/api/tuning.md +31 -23
- 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
117
|
-
/**
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
123
|
-
const
|
|
124
|
-
|
|
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
|
|
176
|
-
* every note in the corpus, so
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
740
|
-
// being thrown away, which is why the automatic breaks looked
|
|
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
|
|
835
|
-
// graduals, every one of them overran
|
|
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
|
|
988
|
-
//
|
|
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,
|
|
1221
|
-
//
|
|
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.
|
|
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 —
|
|
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 >=
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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;
|
package/dist/engines/score/ir.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
11
|
-
//
|
|
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;
|