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