tonus 0.1.8 → 0.5.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/BIBLIOGRAPHY.md +132 -108
- package/CHANGELOG.md +598 -1
- package/LICENSE +133 -29
- package/README.md +106 -83
- package/dist/data/am.js +2666 -11196
- package/dist/data/ams.d.ts +5 -0
- package/dist/data/ams.js +122 -0
- package/dist/data/attestation.d.ts +19 -0
- package/dist/data/attestation.js +15716 -0
- package/dist/data/attestation.json +15711 -0
- package/dist/data/cadentiae.d.ts +43 -0
- package/dist/data/cadentiae.js +174 -0
- package/dist/data/cal.js +48 -0
- package/dist/data/census.d.ts +12 -0
- package/dist/data/census.js +36 -0
- package/dist/data/commune-office.d.ts +4 -0
- package/dist/data/commune-office.js +2371 -0
- package/dist/data/commune-office.json +2365 -0
- package/dist/data/commune.js +181 -11
- package/dist/data/corpus-overlap.d.ts +17 -0
- package/dist/data/corpus-overlap.js +299 -5
- package/dist/data/cot.d.ts +5 -0
- package/dist/data/cot.js +172 -0
- package/dist/data/cse.d.ts +5 -0
- package/dist/data/cse.js +122 -0
- package/dist/data/gabc-glyphs.d.ts +45 -0
- package/dist/data/gabc-glyphs.js +122 -0
- package/dist/data/gr.js +754 -6394
- package/dist/data/kyriale.js +116 -116
- package/dist/data/la.js +799 -13419
- package/dist/data/lh.js +113 -3473
- package/dist/data/lu.js +931 -17631
- package/dist/data/nocturnale-romanum.js +1659 -10411
- package/dist/data/office-ferial.d.ts +4 -0
- package/dist/data/office-ferial.js +396 -0
- package/dist/data/office-ferial.json +391 -0
- package/dist/data/office-monastic.d.ts +17 -1
- package/dist/data/office-monastic.js +1403 -466
- package/dist/data/office-psalms-monastic.d.ts +13 -1
- package/dist/data/office-psalms-monastic.js +9 -0
- package/dist/data/propers.js +1 -1
- package/dist/data/psalms.js +22919 -5
- package/dist/data/psm.d.ts +5 -0
- package/dist/data/psm.js +122 -0
- package/dist/data/seasonal-respbreve.d.ts +5 -0
- package/dist/data/seasonal-respbreve.js +41 -0
- package/dist/data/seasonal-respbreve.json +35 -0
- package/dist/data/smufl-glyphs.d.ts +17 -0
- package/dist/data/smufl-glyphs.js +1546 -0
- package/dist/data/smufl-glyphs.json +1530 -0
- package/dist/engines/cal/calendar.d.ts +3 -2
- package/dist/engines/cal/calendar.js +105 -29
- package/dist/engines/cal/data/eras.d.ts +35 -0
- package/dist/engines/cal/data/eras.js +128 -0
- package/dist/engines/cal/date.js +44 -0
- package/dist/engines/cal/types.d.ts +15 -3
- package/dist/engines/cal/types.js +5 -5
- package/dist/engines/census/census.d.ts +7 -0
- package/dist/engines/census/census.js +179 -0
- package/dist/engines/census/types.d.ts +55 -0
- package/dist/engines/census/types.js +8 -0
- package/dist/engines/chant/attest.d.ts +39 -0
- package/dist/engines/chant/attest.js +90 -0
- package/dist/engines/chant/chant.d.ts +16 -4
- package/dist/engines/chant/chant.js +220 -30
- package/dist/engines/chant/data/compline.js +2 -1
- package/dist/engines/chant/data/masses.d.ts +56 -4
- package/dist/engines/chant/data/masses.js +305 -80
- package/dist/engines/chant/data/prime.js +1 -1
- package/dist/engines/chant/hour.js +279 -58
- package/dist/engines/chant/ordinary.d.ts +2 -0
- package/dist/engines/chant/ordinary.js +336 -56
- package/dist/engines/chant/propers.js +55 -5
- package/dist/engines/chant/psalm.d.ts +4 -4
- package/dist/engines/chant/psalm.js +25 -11
- package/dist/engines/chant/syllabify.d.ts +1 -0
- package/dist/engines/chant/syllabify.js +90 -17
- package/dist/engines/chant/types.d.ts +115 -12
- package/dist/engines/chant/types.js +38 -3
- package/dist/engines/harmonia/api.js +4 -0
- package/dist/engines/harmonia/data/doctrines.js +3 -1
- package/dist/engines/harmonia/tabula.d.ts +3 -0
- package/dist/engines/harmonia/tabula.js +1 -0
- package/dist/engines/harmonia/voice.d.ts +4 -0
- package/dist/engines/harmonia/voice.js +8 -4
- package/dist/engines/imprint.js +14 -1
- package/dist/engines/planet/orbital.js +4 -4
- package/dist/engines/planet/planet.d.ts +10 -0
- package/dist/engines/planet/planet.js +30 -3
- package/dist/engines/planet/position.js +13 -10
- package/dist/engines/planet/types.d.ts +1 -0
- package/dist/engines/score/api.d.ts +2 -13
- package/dist/engines/score/api.js +21 -8
- package/dist/engines/score/articulation.js +2 -2
- package/dist/engines/score/cadence.d.ts +76 -0
- package/dist/engines/score/cadence.js +96 -0
- package/dist/engines/score/emitters/accidentals.d.ts +21 -0
- package/dist/engines/score/emitters/accidentals.js +88 -0
- package/dist/engines/score/emitters/atramentum.d.ts +107 -0
- package/dist/engines/score/emitters/atramentum.js +239 -0
- package/dist/engines/score/emitters/breaking.d.ts +62 -0
- package/dist/engines/score/emitters/breaking.js +80 -0
- package/dist/engines/score/emitters/moderna.d.ts +38 -0
- package/dist/engines/score/emitters/moderna.js +612 -0
- package/dist/engines/score/emitters/svg.d.ts +143 -0
- package/dist/engines/score/emitters/svg.js +1328 -0
- package/dist/engines/score/emitters/tracks.d.ts +104 -0
- package/dist/engines/score/emitters/tracks.js +728 -0
- package/dist/engines/score/infer.d.ts +3 -3
- package/dist/engines/score/infer.js +2 -2
- package/dist/engines/score/inscriptio.d.ts +69 -0
- package/dist/engines/score/inscriptio.js +138 -0
- package/dist/engines/score/ir.d.ts +2 -2
- package/dist/engines/score/ir.js +59 -12
- package/dist/engines/score/lyric.d.ts +23 -0
- package/dist/engines/score/lyric.js +234 -0
- package/dist/engines/score/meta.d.ts +2 -2
- package/dist/engines/score/modulation.d.ts +12 -0
- package/dist/engines/score/modulation.js +49 -0
- package/dist/engines/score/neume.js +35 -4
- package/dist/engines/score/parse.js +150 -9
- package/dist/engines/score/phrasing.js +4 -3
- package/dist/engines/score/prosody.d.ts +38 -0
- package/dist/engines/score/prosody.js +70 -6
- package/dist/engines/score/tabula.d.ts +37 -5
- package/dist/engines/score/tabula.js +18 -0
- package/dist/engines/score/types.d.ts +88 -1
- package/dist/engines/temper/api.d.ts +4 -1
- package/dist/engines/temper/api.js +28 -5
- package/dist/engines/temper/data/guido.js +6 -2
- package/dist/engines/temper/data/modes.d.ts +6 -0
- package/dist/engines/temper/data/modes.js +42 -0
- package/dist/engines/temper/data/tones.d.ts +1 -1
- package/dist/engines/temper/data/tones.js +20 -11
- package/dist/engines/temper/interval.js +4 -3
- package/dist/engines/temper/modality.d.ts +11 -2
- package/dist/engines/temper/modality.js +74 -2
- package/dist/engines/temper/modes.d.ts +1 -1
- package/dist/engines/temper/pitch.d.ts +1 -1
- package/dist/engines/temper/pitch.js +12 -2
- package/dist/engines/temper/scale.d.ts +53 -0
- package/dist/engines/temper/scale.js +107 -8
- package/dist/index.d.ts +26 -8
- package/dist/index.js +37 -4
- package/docs/api/calendar.md +279 -0
- package/docs/api/census.md +288 -0
- package/docs/api/chant.md +657 -0
- package/docs/api/heavens.md +346 -0
- package/docs/api/index.md +263 -0
- package/docs/api/score.md +873 -0
- package/docs/api/tuning.md +619 -0
- package/package.json +11 -5
- package/dist/data/office-matins-roman.d.ts +0 -19
- package/dist/data/office-matins-roman.js +0 -4383
- package/dist/data/office-psalms-roman.d.ts +0 -15
- package/dist/data/office-psalms-roman.js +0 -28
- package/dist/data/office-roman.d.ts +0 -19
- package/dist/data/office-roman.js +0 -13792
- package/dist/engines/chant/matutinum.d.ts +0 -33
- package/dist/engines/chant/matutinum.js +0 -81
- package/dist/engines/score/emitters/midi.d.ts +0 -65
- package/dist/engines/score/emitters/midi.js +0 -162
- package/dist/engines/score/emitters/musicxml.d.ts +0 -18
- package/dist/engines/score/emitters/musicxml.js +0 -166
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import { computeModalAffinity } from "../temper/modality.js";
|
|
2
|
+
import { MODES } from "../temper/data/modes.js";
|
|
2
3
|
// How much a foreign mode must outscore the home mode (in normalised affinity)
|
|
3
4
|
// before a phrase counts as leaning away. Calibrated against Suñol's worked
|
|
4
5
|
// examples [biblio: sunol-textbook]: at 0.25 the modulations he names in Christus
|
|
5
6
|
// resurgens (to mode 3) register, while incidental colouring below that does not.
|
|
6
7
|
const MARGIN = 0.25;
|
|
8
|
+
// A phrase leaning toward its own mode's authentic/plagal partner is NOT
|
|
9
|
+
// modulating: the partner shares the final, so the "lean" is AMBITUS — the
|
|
10
|
+
// phrase sitting high or low within the same maneria — not a tonal-centre
|
|
11
|
+
// shift. Measured over the corpus: partner leans were 18% of the lean-pairs behind
|
|
12
|
+
// the 28% of moded chants that read as wall-to-wall foreign. A category
|
|
13
|
+
// correction, not a threshold.
|
|
14
|
+
const maneriaPartner = (mode) => mode % 2 === 1 ? mode + 1 : mode - 1; // 1↔2, 3↔4, 5↔6, 7↔8
|
|
15
|
+
// A chant is read as TRANSPOSED (not modulating) when it does not close on its
|
|
16
|
+
// labeled mode's final AND a single foreign mode's spans cover most of its
|
|
17
|
+
// phrases. Corpus context: 81.6% of labeled chants close on their mode's
|
|
18
|
+
// final; of the remainder, the wall-to-wall foreign
|
|
19
|
+
// readings this rule reclassifies were 30% of ALL labeled chants before it.
|
|
20
|
+
const TRANSPOSITION_SHARE = 0.6;
|
|
7
21
|
/** The pitch-class distribution of one phrase's notes (fractions summing to 1). */
|
|
8
22
|
function phrasePcDistribution(phrase) {
|
|
9
23
|
const counts = new Array(12).fill(0);
|
|
@@ -38,6 +52,8 @@ export function detectModulations(phrases, homeMode) {
|
|
|
38
52
|
const top = affinity[0];
|
|
39
53
|
if (!top || top.mode === homeMode)
|
|
40
54
|
return null;
|
|
55
|
+
if (top.mode === maneriaPartner(homeMode))
|
|
56
|
+
return null;
|
|
41
57
|
const home = affinity.find((a) => a.mode === homeMode);
|
|
42
58
|
const margin = top.score - (home?.score ?? 0);
|
|
43
59
|
return margin >= MARGIN ? { mode: top.mode, margin } : null;
|
|
@@ -54,6 +70,7 @@ export function detectModulations(phrases, homeMode) {
|
|
|
54
70
|
endPhrase: run.start + run.margins.length - 1,
|
|
55
71
|
toMode: run.mode,
|
|
56
72
|
confidence: Math.min(1, Math.round(avg * 100) / 100),
|
|
73
|
+
kind: run.margins.length === 1 ? "inflection" : "modulation",
|
|
57
74
|
});
|
|
58
75
|
run = null;
|
|
59
76
|
};
|
|
@@ -69,6 +86,38 @@ export function detectModulations(phrases, homeMode) {
|
|
|
69
86
|
}
|
|
70
87
|
}
|
|
71
88
|
flush();
|
|
89
|
+
// Transposition, not modulation: the chant does not close on its labeled
|
|
90
|
+
// final, and one foreign mode's spans dominate its phrases — the displacement
|
|
91
|
+
// is global (affinal notation, or a label at odds with the notation).
|
|
92
|
+
const homeFinal = MODES.get(homeMode)?.final;
|
|
93
|
+
let closingPc;
|
|
94
|
+
for (let pi = phrases.length - 1; pi >= 0 && closingPc == null; pi--) {
|
|
95
|
+
for (let si = phrases[pi].syllables.length - 1; si >= 0; si--) {
|
|
96
|
+
const notes = phrases[pi].syllables[si].notes;
|
|
97
|
+
if (notes.length > 0) {
|
|
98
|
+
closingPc = notes[notes.length - 1].pitch.pc;
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (homeFinal != null && closingPc != null && closingPc !== homeFinal % 12) {
|
|
104
|
+
const coverage = new Map();
|
|
105
|
+
for (const m of modulations) {
|
|
106
|
+
coverage.set(m.toMode, (coverage.get(m.toMode) ?? 0) + (m.endPhrase - m.startPhrase + 1));
|
|
107
|
+
}
|
|
108
|
+
// The share is over phrases that SOUND — a note-less phrase (an isolated
|
|
109
|
+
// divisio, an empty incise) can neither lean nor stay home, so counting it
|
|
110
|
+
// in the denominator quietly diluted every chant that carried one.
|
|
111
|
+
const sounding = phrases.filter((p) => !p.syllables.every((s) => s.notes.length === 0)).length;
|
|
112
|
+
for (const [toMode, phraseCount] of coverage) {
|
|
113
|
+
if (sounding > 0 && phraseCount / sounding >= TRANSPOSITION_SHARE) {
|
|
114
|
+
for (const m of modulations) {
|
|
115
|
+
if (m.toMode === toMode)
|
|
116
|
+
m.kind = "transposition";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
72
121
|
return modulations;
|
|
73
122
|
}
|
|
74
123
|
//# sourceMappingURL=modulation.js.map
|
|
@@ -16,11 +16,42 @@ export function classifyNeume(notes) {
|
|
|
16
16
|
}
|
|
17
17
|
const dirs = intervals.map(toDirection);
|
|
18
18
|
let type = classifyShape(dirs);
|
|
19
|
-
// Salicus:
|
|
20
|
-
//
|
|
21
|
-
//
|
|
19
|
+
// Salicus: "a neume with at least three ascending notes in which the
|
|
20
|
+
// next-to-last is an oriscus" [biblio: cardine-semiology, ch. 16]. The
|
|
21
|
+
// ORISCUS is what makes it one — not the editorial ictus.
|
|
22
|
+
//
|
|
23
|
+
// RULED 2026-08-04 (Jeffrey): adopt Cardine's definition for DETECTION, not
|
|
24
|
+
// only for the prolongation. This is a second doctrine call beyond the
|
|
25
|
+
// original ruling and was ratified on its own terms, because it decides what
|
|
26
|
+
// the word `salicus` denotes everywhere in tonus — the neume type, the
|
|
27
|
+
// always-arsic override, the chironomia track, any caller reading
|
|
28
|
+
// `neume.type`.
|
|
29
|
+
//
|
|
30
|
+
// This detection was previously keyed on the Solesmes ictus (GABC `'`) on
|
|
31
|
+
// the second-to-last note, which is a different thing and a much larger set.
|
|
32
|
+
// Measured over the sung corpus today: 302 salici against 2,960 scandici, so
|
|
33
|
+
// the old rule claimed roughly 3,262 groups where 302 are real — and of that
|
|
34
|
+
// wider set only 9.4% carry an oriscus at all. An ictus-marked ascent with no
|
|
35
|
+
// oriscus IS a scandicus that Solesmes marked for rhythm; the mark survives
|
|
36
|
+
// on `context.ictus` for anyone reading it. Bevenot calls the conflation "a
|
|
37
|
+
// trap" [biblio: cardine-semiology].
|
|
38
|
+
//
|
|
39
|
+
// (This paragraph read 2,795 / 36 / 226 until 2026-08-11. Those came from
|
|
40
|
+
// working/qa-sweep/salicus-before.json, a run over 2,887 chants and 28,498
|
|
41
|
+
// phrases — a corpus that predates the accidental fix in `parse.ts`, which
|
|
42
|
+
// was emitting a phantom note at every accidental and inflating every count
|
|
43
|
+
// downstream. The shape of the finding survived re-measurement; the figures
|
|
44
|
+
// did not.)
|
|
45
|
+
//
|
|
46
|
+
// The LIMIT of this, stated because it is easy to mistake for a bug: tonus
|
|
47
|
+
// sees only what the transcription marks. Bevenot's own example — the mode-6
|
|
48
|
+
// Requiem introit's fa-sol-la — carries the Solesmes ictus and NO oriscus in
|
|
49
|
+
// GregoBase (gregobase:766, on "ae" and "do"), so it reads here as a
|
|
50
|
+
// scandicus. He is reading the manuscripts; we are reading a printed edition
|
|
51
|
+
// that resolved the oriscus away. Recovering those needs the MSS, not a rule
|
|
52
|
+
// change: a guess dressed as a measurement is worse than the gap.
|
|
22
53
|
const allAscending = dirs.length >= 2 && dirs.every((d) => d === "up");
|
|
23
|
-
if (allAscending && notes[notes.length - 2].context.
|
|
54
|
+
if (allAscending && notes[notes.length - 2].context.oriscus) {
|
|
24
55
|
type = "salicus";
|
|
25
56
|
}
|
|
26
57
|
return { type, intervals, hasQuilisma, hasLiquescent, hasStrophicus };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildArticulation } from "./articulation.js";
|
|
2
|
+
import { createLyricDecoder } from "./lyric.js";
|
|
2
3
|
import { detectVowelAccent } from "../chant/syllabify.js";
|
|
3
4
|
// Constants
|
|
4
5
|
const DEFAULT_OPTIONS = {
|
|
@@ -45,18 +46,21 @@ function initialAccidentalState(clef) {
|
|
|
45
46
|
state.set(6, -1); // B-flat key signature
|
|
46
47
|
return state;
|
|
47
48
|
}
|
|
49
|
+
function isLineBreak(token) {
|
|
50
|
+
// GABC's explicit break: `z` a line break, `Z` a page break, `z0` a break
|
|
51
|
+
// that suppresses the custos. tonus paginates nothing, so all three mean the
|
|
52
|
+
// same thing here — end the system.
|
|
53
|
+
return token === "z" || token === "Z" || token === "z0";
|
|
54
|
+
}
|
|
48
55
|
function isSkippable(token) {
|
|
49
56
|
return (token === " " ||
|
|
50
|
-
token === "z" ||
|
|
51
|
-
token === "Z" ||
|
|
52
|
-
token === "z0" ||
|
|
53
57
|
token === "\r" ||
|
|
54
58
|
token.startsWith("{") ||
|
|
55
59
|
token.includes("+"));
|
|
56
60
|
}
|
|
57
61
|
// parseNeume
|
|
58
62
|
function parseNeume(notation, context) {
|
|
59
|
-
const { lyric, clef, oct, syllableIndex, accent, accidentalState, profile } = context;
|
|
63
|
+
const { lyric, runs, keepWithPrev, clef, oct, syllableIndex, accent, accidentalState, pending, profile } = context;
|
|
60
64
|
const weights = profile.weights;
|
|
61
65
|
const ruleGain = profile.ruleGain ?? 1.0;
|
|
62
66
|
const contourScale = profile.contourScale ?? 0.2;
|
|
@@ -96,12 +100,15 @@ function parseNeume(notation, context) {
|
|
|
96
100
|
w += weights.dashWeight; // negative weight
|
|
97
101
|
durWeight += weights.dashDuration;
|
|
98
102
|
}
|
|
99
|
-
const
|
|
103
|
+
const rawLetter = token[0];
|
|
104
|
+
const letter = rawLetter?.toLowerCase();
|
|
100
105
|
if (!letter)
|
|
101
106
|
return;
|
|
102
107
|
const pitchOffset = letter.charCodeAt(0) - "a".charCodeAt(0);
|
|
103
108
|
if (pitchOffset < 0 || pitchOffset > 12)
|
|
104
109
|
return;
|
|
110
|
+
// An uppercase pitch letter is a punctum inclinatum (the small diamond).
|
|
111
|
+
const isInclinatum = rawLetter !== letter;
|
|
105
112
|
const clefOffset = CLEF_OFFSETS.get(clef) ?? 0;
|
|
106
113
|
const pos = pitchOffset - 6 - clefOffset;
|
|
107
114
|
const octave = Math.floor(pos / 7) + oct + 1;
|
|
@@ -125,6 +132,29 @@ function parseNeume(notation, context) {
|
|
|
125
132
|
explicitAccidental = 1;
|
|
126
133
|
accidentalState.set(degree, 1);
|
|
127
134
|
}
|
|
135
|
+
// An accidental is a MARK, not a note. `fx` says "F is flat from here" and
|
|
136
|
+
// is drawn as a flat sign on the F line — nothing is sung at that moment.
|
|
137
|
+
// The state above is its whole effect, so the token ends here.
|
|
138
|
+
//
|
|
139
|
+
// It used to fall through and emit a note at the flattened pitch, which is
|
|
140
|
+
// why `A(fxfg)` returned three notes for two and duplicated the pitch: the
|
|
141
|
+
// marker sounded, then the F it governs sounded again. 1337 markers across
|
|
142
|
+
// 426 Graduale chants — 0.97% of every note in the book was a phantom, with
|
|
143
|
+
// a wrong pitch, inflating counts and inventing an interval of a unison
|
|
144
|
+
// before each one.
|
|
145
|
+
//
|
|
146
|
+
// The token is the mark alone: a bare pitch letter plus x/y/#. A letter
|
|
147
|
+
// carrying an accidental AND other modifiers is still a real note (nothing
|
|
148
|
+
// in the corpus writes one, but the reading is the safe one).
|
|
149
|
+
if (explicitAccidental !== null && /^[a-mA-M][xy#]$/.test(token)) {
|
|
150
|
+
// The SIGN still belongs on the page — a flat is drawn before the note it
|
|
151
|
+
// governs, which is where the books put it. The mark had been riding the
|
|
152
|
+
// phantom note itself; with that gone it is handed to the next real note,
|
|
153
|
+
// which is both correct notation and what the emitters already expect
|
|
154
|
+
// (they key the glyph off `accidentalSource === "explicit"`).
|
|
155
|
+
pending.acc = explicitAccidental;
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
128
158
|
// Apply bmolle to B (degree 6 = B natural step 11 → B-flat step 10)
|
|
129
159
|
let step = baseStep;
|
|
130
160
|
if (bmolle && degree === 6)
|
|
@@ -137,13 +167,28 @@ function parseNeume(notation, context) {
|
|
|
137
167
|
step += activeAccidental;
|
|
138
168
|
}
|
|
139
169
|
step += octave * 12;
|
|
140
|
-
|
|
170
|
+
// A sign handed over by a preceding accidental token is drawn AT this note,
|
|
171
|
+
// whatever its degree. A GABC accidental is a key-signature mark, not an
|
|
172
|
+
// alteration of the note beside it: `gx` says "G is flat for the rest of the
|
|
173
|
+
// word" and the books print the flat where it is written, then sing on.
|
|
174
|
+
// Measured across 693 corpus markers, only 11% are immediately followed by
|
|
175
|
+
// the same letter — so requiring the degrees to agree (my first attempt)
|
|
176
|
+
// suppressed the sign in nine cases out of ten.
|
|
177
|
+
const inheritedSign = pending.acc;
|
|
178
|
+
if (inheritedSign !== null)
|
|
179
|
+
pending.acc = null;
|
|
180
|
+
const inherited = inheritedSign !== null;
|
|
181
|
+
const accidentalSource = explicitAccidental !== null || inherited
|
|
141
182
|
? "explicit"
|
|
142
183
|
: accidentalState.has(degree)
|
|
143
184
|
? "state"
|
|
144
185
|
: "none";
|
|
145
|
-
// Ictus markers (' and _)
|
|
146
|
-
|
|
186
|
+
// Ictus markers (' and _). Both signal the rhythmic footfall (weight
|
|
187
|
+
// unchanged), but they are DISTINCT written marks the renderer must draw
|
|
188
|
+
// apart: ' is the vertical episema (ictus mark), _ the horizontal episema.
|
|
189
|
+
const hasIctusSign = modifiers.includes("'");
|
|
190
|
+
const hasEpisema = modifiers.includes("_");
|
|
191
|
+
if (hasIctusSign || hasEpisema) {
|
|
147
192
|
w += weights.ictusWeight;
|
|
148
193
|
durWeight += weights.ictusDuration;
|
|
149
194
|
ictus = true;
|
|
@@ -228,14 +273,43 @@ function parseNeume(notation, context) {
|
|
|
228
273
|
prev._weight += weights.repercussionOriscusWeight;
|
|
229
274
|
}
|
|
230
275
|
}
|
|
276
|
+
// Written note shape (gregorio's S_* vocabulary, simplified) — what the
|
|
277
|
+
// renderer draws. Priority: the inclinatum diamond and the ornamental
|
|
278
|
+
// shapes win over the plain punctum/virga forms.
|
|
279
|
+
let shape = "punctum";
|
|
280
|
+
if (isInclinatum)
|
|
281
|
+
shape = "inclinatum";
|
|
282
|
+
else if (isQuilisma)
|
|
283
|
+
shape = "quilisma";
|
|
284
|
+
else if (isStrophicus)
|
|
285
|
+
shape = "strophicus";
|
|
286
|
+
else if (modifiers.includes("V"))
|
|
287
|
+
shape = "virgaReversa";
|
|
288
|
+
else if (modifiers.includes("v"))
|
|
289
|
+
shape = "virga";
|
|
290
|
+
else if (isOriscus)
|
|
291
|
+
shape = "oriscus";
|
|
292
|
+
else if (modifiers.includes("r"))
|
|
293
|
+
shape = "cavum";
|
|
294
|
+
else if (modifiers.includes("="))
|
|
295
|
+
shape = "linea";
|
|
231
296
|
intermed.push({
|
|
232
297
|
step,
|
|
233
298
|
degree,
|
|
234
299
|
lyric,
|
|
300
|
+
runs,
|
|
301
|
+
keepWithPrev,
|
|
235
302
|
syllableIndex,
|
|
236
303
|
neumeGroup,
|
|
304
|
+
staffLetter: letter,
|
|
305
|
+
clef,
|
|
306
|
+
shape,
|
|
237
307
|
ictus,
|
|
308
|
+
ictusSign: hasIctusSign,
|
|
309
|
+
episema: hasEpisema,
|
|
238
310
|
accidental: activeAccidental,
|
|
311
|
+
...(inheritedSign !== null ? { accidentalSign: inheritedSign } : {}),
|
|
312
|
+
...(explicitAccidental !== null ? { accidentalSign: explicitAccidental } : {}),
|
|
239
313
|
accidentalSource,
|
|
240
314
|
quilisma: isQuilisma,
|
|
241
315
|
liquescent: isLiquescent,
|
|
@@ -285,12 +359,24 @@ function parseNeume(notation, context) {
|
|
|
285
359
|
type: "note",
|
|
286
360
|
step: note.step,
|
|
287
361
|
lyric: note.lyric,
|
|
362
|
+
runs: note.runs,
|
|
363
|
+
keepWithPrev: note.keepWithPrev,
|
|
288
364
|
syllableIndex: note.syllableIndex,
|
|
289
365
|
neumeGroup: note.neumeGroup,
|
|
366
|
+
// Tonic word-accent of this note's syllable (Latin accentuation), the same
|
|
367
|
+
// boolean the articulation weighting reads above — surfaced as data so the
|
|
368
|
+
// tabula carries it (accent stats, prose-cursus). Syllable-wide constant.
|
|
369
|
+
accent,
|
|
370
|
+
staffLetter: note.staffLetter,
|
|
371
|
+
clef: note.clef,
|
|
372
|
+
shape: note.shape,
|
|
290
373
|
ictus: note.ictus,
|
|
374
|
+
ictusSign: note.ictusSign,
|
|
375
|
+
episema: note.episema,
|
|
291
376
|
weight,
|
|
292
377
|
duration,
|
|
293
378
|
accidental: note.accidental,
|
|
379
|
+
...(note.accidentalSign !== undefined ? { accidentalSign: note.accidentalSign } : {}),
|
|
294
380
|
accidentalSource: note.accidentalSource,
|
|
295
381
|
quilisma: note.quilisma,
|
|
296
382
|
liquescent: note.liquescent,
|
|
@@ -316,16 +402,38 @@ export function parseGABC(gabc, options = {}) {
|
|
|
316
402
|
};
|
|
317
403
|
}
|
|
318
404
|
let currentClef = "c3";
|
|
405
|
+
// A `z` sets this, and the next note born carries it. The marker usually sits
|
|
406
|
+
// in its own group — "…son.(c.) (z) Chri(h)ste…" — so the flag has to outlive
|
|
407
|
+
// the syllable it appeared in rather than being consumed where it was seen.
|
|
408
|
+
let pendingLineBreak = false;
|
|
319
409
|
let accidentalState = initialAccidentalState(currentClef);
|
|
410
|
+
// An accidental token draws a sign but sounds nothing, so the sign is claimed
|
|
411
|
+
// by the next real note. It must survive the syllable boundary: the marker
|
|
412
|
+
// very often sits ALONE in its own group — "a(gx)b(a)" — so a flag local to
|
|
413
|
+
// one neume would be discarded before the note it governs is parsed.
|
|
414
|
+
const pendingAccidental = { acc: null };
|
|
415
|
+
// One lyric decoder per source: GABC style tags open and close across
|
|
416
|
+
// syllable (and word) boundaries, so the decode state rides the whole walk.
|
|
417
|
+
const lyricDecoder = createLyricDecoder();
|
|
320
418
|
const split = source.replace(/\)\s(?=[^\)]*(?:\(|$))/g, ")\n").split(/\n/g);
|
|
321
419
|
split.forEach((word) => {
|
|
322
420
|
if (!word)
|
|
323
421
|
return;
|
|
422
|
+
// A flat holds for the rest of the WORD or until a divisio — a new word
|
|
423
|
+
// clears any accidental the previous word set (the clef's own signature
|
|
424
|
+
// persists). This is the scope the header states, now enforced.
|
|
425
|
+
accidentalState = initialAccidentalState(currentClef);
|
|
426
|
+
pendingAccidental.acc = null; // a new clef clears any unclaimed sign
|
|
324
427
|
SYLLABLES_REGEX.lastIndex = 0;
|
|
325
428
|
let match;
|
|
326
429
|
let syllableIndex = 0;
|
|
327
430
|
while ((match = SYLLABLES_REGEX.exec(word)) !== null) {
|
|
328
|
-
const
|
|
431
|
+
const rawText = (match[1] ? match[1].trim().split("|")[0] : "") || "";
|
|
432
|
+
// Decode GABC lyric markup EVERY syllable — a style opened here may
|
|
433
|
+
// close syllables later, so the decoder's state must advance even when
|
|
434
|
+
// this syllable carries no notes.
|
|
435
|
+
const decoded = lyricDecoder.decode(rawText);
|
|
436
|
+
const text = decoded.text;
|
|
329
437
|
const notation = match[2] ? match[2].match(NOTATIONS_REGEX) : null;
|
|
330
438
|
if (!notation || notation.length === 0) {
|
|
331
439
|
syllableIndex += 1;
|
|
@@ -338,6 +446,7 @@ export function parseGABC(gabc, options = {}) {
|
|
|
338
446
|
if (CLEF_OFFSETS.has(token)) {
|
|
339
447
|
currentClef = token;
|
|
340
448
|
accidentalState = initialAccidentalState(currentClef);
|
|
449
|
+
pendingAccidental.acc = null; // a new clef clears any unclaimed sign
|
|
341
450
|
continue;
|
|
342
451
|
}
|
|
343
452
|
if (DIVISIO_DURATIONS.has(token)) {
|
|
@@ -345,21 +454,52 @@ export function parseGABC(gabc, options = {}) {
|
|
|
345
454
|
divisioToken = token;
|
|
346
455
|
continue;
|
|
347
456
|
}
|
|
457
|
+
// A BREAK IS ITS OWN GROUP. GABC writes one as `(z)`, alone; a `z`
|
|
458
|
+
// that FOLLOWS notes inside a group is not one. The tokenizer splits
|
|
459
|
+
// `ra(gz)ti` into ["g","z"] because `z` is not in a note's suffix
|
|
460
|
+
// class, and that stray token was read as the engraver's break — so
|
|
461
|
+
// three responsories in the Nocturnale cut their systems a third of
|
|
462
|
+
// the way across the page and left the rest of the line empty.
|
|
463
|
+
//
|
|
464
|
+
// The rule is NOTES BEFORE IT, not "alone in its group": `(z0::c3)` is
|
|
465
|
+
// a real break carrying the double bar and the new clef after it, and
|
|
466
|
+
// those are common. What is never a break is a `z` that FOLLOWS
|
|
467
|
+
// notes, which is what `(gz)` and `(hjHG__z)` are.
|
|
468
|
+
if (isLineBreak(token)) {
|
|
469
|
+
if (noteTokens.length === 0)
|
|
470
|
+
pendingLineBreak = true;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
348
473
|
if (isSkippable(token))
|
|
349
474
|
continue;
|
|
350
475
|
noteTokens.push(token);
|
|
351
476
|
}
|
|
352
477
|
if (noteTokens.length > 0) {
|
|
478
|
+
const before = events.length;
|
|
353
479
|
const accent = opts.useVowelAccent ? detectVowelAccent(text) : false;
|
|
354
480
|
events.push(...parseNeume(noteTokens, {
|
|
355
481
|
lyric: text,
|
|
482
|
+
runs: decoded.runs,
|
|
483
|
+
keepWithPrev: decoded.keepWithPrev,
|
|
356
484
|
clef: currentClef,
|
|
357
485
|
oct: opts.oct,
|
|
358
486
|
syllableIndex,
|
|
359
487
|
accent,
|
|
360
488
|
accidentalState,
|
|
489
|
+
pending: pendingAccidental,
|
|
361
490
|
profile: articulation,
|
|
362
491
|
}));
|
|
492
|
+
// The break lands on the FIRST note the marker precedes, and is
|
|
493
|
+
// spent there. A `z` usually sits in a group with NO notes of its
|
|
494
|
+
// own — "…(::) (z)Ps. E(g)ru…" — so the flag outlives that group
|
|
495
|
+
// and is claimed by the next note born, which is the one the
|
|
496
|
+
// engraver meant to start the new line.
|
|
497
|
+
if (pendingLineBreak) {
|
|
498
|
+
const first = events[before];
|
|
499
|
+
if (first && first.type === "note")
|
|
500
|
+
first.lineBreak = true;
|
|
501
|
+
pendingLineBreak = false;
|
|
502
|
+
}
|
|
363
503
|
}
|
|
364
504
|
if (divisioToken) {
|
|
365
505
|
events.push({
|
|
@@ -368,6 +508,7 @@ export function parseGABC(gabc, options = {}) {
|
|
|
368
508
|
duration: DIVISIO_DURATIONS.get(divisioToken) ?? 0.5,
|
|
369
509
|
});
|
|
370
510
|
accidentalState = initialAccidentalState(currentClef);
|
|
511
|
+
pendingAccidental.acc = null; // a new clef clears any unclaimed sign
|
|
371
512
|
}
|
|
372
513
|
syllableIndex += 1;
|
|
373
514
|
}
|
|
@@ -161,9 +161,10 @@ const TENOR_GAIN = 0.05;
|
|
|
161
161
|
const TENOR_DISTANCE_DIVISOR = 6;
|
|
162
162
|
const CADENCE_VELOCITY_FACTOR = 0.5;
|
|
163
163
|
const CADENCE_DURATION_FACTOR = 0.6;
|
|
164
|
-
// The phrasing-side reading of the divisio (bar-line) hierarchy. This is the
|
|
165
|
-
// bar hierarchy tabulated canonically in docs/score.md and
|
|
166
|
-
// by prosody.ts's cadenceWeight ladder — but here the
|
|
164
|
+
// The phrasing-side reading of the divisio (bar-line) hierarchy. This is the
|
|
165
|
+
// same bar hierarchy tabulated canonically in ../../../docs/api/score.md and
|
|
166
|
+
// weighted for analysis by prosody.ts's cadenceWeight ladder — but here the
|
|
167
|
+
// weights differ on purpose:
|
|
167
168
|
// this is a *shaping* factor, so the virgula (`) gets 0 (a breath, no cadential
|
|
168
169
|
// stress), where the analytic ladder still counts it. Do not unify the three.
|
|
169
170
|
const DIVISIO_STRENGTH = {
|
|
@@ -18,6 +18,36 @@ export interface RhythmicProfile {
|
|
|
18
18
|
avgGroupSize: number;
|
|
19
19
|
maxGroupSize: number;
|
|
20
20
|
}
|
|
21
|
+
/** Melodic-motion statistics over adjacent notes (within a phrase). */
|
|
22
|
+
export interface IntervalStats {
|
|
23
|
+
/** Signed semitone interval → count. Movement across a divisio is excluded. */
|
|
24
|
+
histogram: Record<number, number>;
|
|
25
|
+
/** Largest absolute interval, in semitones. */
|
|
26
|
+
maxLeap: number;
|
|
27
|
+
/** Fraction of motions that are leaps (≥ a fourth, 5+ semitones). */
|
|
28
|
+
leapRate: number;
|
|
29
|
+
/** Motion by class: step (1–2 st), skip (a third, 3–4), leap (a fourth+, 5+). */
|
|
30
|
+
motus: {
|
|
31
|
+
step: number;
|
|
32
|
+
skip: number;
|
|
33
|
+
leap: number;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** The melodic arch of the piece: where it begins, peaks, and ends, and its shape. */
|
|
37
|
+
export interface Arcus {
|
|
38
|
+
/** First note's MIDI. */
|
|
39
|
+
initial: number;
|
|
40
|
+
/** Highest note's MIDI. */
|
|
41
|
+
peak: number;
|
|
42
|
+
/** Last note's MIDI. */
|
|
43
|
+
final: number;
|
|
44
|
+
/**
|
|
45
|
+
* Signed arch index: how much the melody rises to its peak versus where it
|
|
46
|
+
* settles. +1 a full arch (rises high, returns low), 0 flat/monotonic,
|
|
47
|
+
* negative when it ends above where it began relative to the climb.
|
|
48
|
+
*/
|
|
49
|
+
archIndex: number;
|
|
50
|
+
}
|
|
21
51
|
export interface Prosody {
|
|
22
52
|
noteCount: number;
|
|
23
53
|
syllableCount: number;
|
|
@@ -26,6 +56,14 @@ export interface Prosody {
|
|
|
26
56
|
ambitus: number | null;
|
|
27
57
|
melismaRatio: number;
|
|
28
58
|
melismaByPhrase: number[];
|
|
59
|
+
/** Mean notes-per-syllable of each phrase's FINAL syllable — the cadential melisma. */
|
|
60
|
+
melismaCadential: number;
|
|
61
|
+
/** Mean pitch minus the final note, in semitones — how high the melody sits above its rest. */
|
|
62
|
+
tessitura: number | null;
|
|
63
|
+
/** Melodic-interval statistics over adjacent within-phrase notes. */
|
|
64
|
+
intervals: IntervalStats;
|
|
65
|
+
/** The melodic arch: initial/peak/final and a signed arch index. */
|
|
66
|
+
arcus: Arcus | null;
|
|
29
67
|
ictusRate: number;
|
|
30
68
|
rhythmicProfile: RhythmicProfile;
|
|
31
69
|
cadenceWeight: number;
|
|
@@ -16,6 +16,18 @@ export function computeProsody(phrases) {
|
|
|
16
16
|
};
|
|
17
17
|
let cadenceWeight = 0;
|
|
18
18
|
const melismaByPhrase = [];
|
|
19
|
+
// Interval statistics — adjacent notes WITHIN a phrase; prevMidi resets to null
|
|
20
|
+
// at each phrase so a breath across a divisio is not counted as a leap.
|
|
21
|
+
const histogram = {};
|
|
22
|
+
let motions = 0, stepCount = 0, skipCount = 0, leapCount = 0, maxLeap = 0;
|
|
23
|
+
let prevMidi = null;
|
|
24
|
+
// Tessitura + arch: running MIDI sum, the first note, the peak, the last note.
|
|
25
|
+
let midiSum = 0;
|
|
26
|
+
let firstMidi = null;
|
|
27
|
+
let lastMidi = 0;
|
|
28
|
+
let peakMidi = -Infinity;
|
|
29
|
+
// Cadential melisma — the note count of each phrase's final sung syllable.
|
|
30
|
+
const cadentialMelismas = [];
|
|
19
31
|
const closeGroup = () => {
|
|
20
32
|
if (currentGroupSize > 0) {
|
|
21
33
|
groupSizes.push(currentGroupSize);
|
|
@@ -27,24 +39,51 @@ export function computeProsody(phrases) {
|
|
|
27
39
|
phraseCount++;
|
|
28
40
|
let phraseNotes = 0;
|
|
29
41
|
let phraseSyllables = 0;
|
|
42
|
+
let lastSylNotes = 0; // notes on this phrase's most recent sung syllable
|
|
43
|
+
prevMidi = null; // intervals never cross a phrase boundary (a breath)
|
|
30
44
|
for (const syl of phrase.syllables) {
|
|
31
45
|
if (syl.notes.length === 0)
|
|
32
46
|
continue;
|
|
33
47
|
phraseSyllables++;
|
|
34
48
|
syllableCount++;
|
|
49
|
+
lastSylNotes = syl.notes.length;
|
|
35
50
|
for (const note of syl.notes) {
|
|
51
|
+
const midi = note.pitch.midi;
|
|
36
52
|
noteCount++;
|
|
37
53
|
phraseNotes++;
|
|
38
|
-
if (
|
|
39
|
-
minMidi =
|
|
40
|
-
if (
|
|
41
|
-
maxMidi =
|
|
54
|
+
if (midi < minMidi)
|
|
55
|
+
minMidi = midi;
|
|
56
|
+
if (midi > maxMidi)
|
|
57
|
+
maxMidi = midi;
|
|
42
58
|
if (note.context.ictus)
|
|
43
59
|
ictusCount++;
|
|
44
60
|
if (note.performance.rhythmicShape === "arsic")
|
|
45
61
|
arsicCount++;
|
|
46
62
|
else
|
|
47
63
|
theticCount++;
|
|
64
|
+
// Tessitura + arch running values.
|
|
65
|
+
midiSum += midi;
|
|
66
|
+
if (firstMidi === null)
|
|
67
|
+
firstMidi = midi;
|
|
68
|
+
if (midi > peakMidi)
|
|
69
|
+
peakMidi = midi;
|
|
70
|
+
lastMidi = midi;
|
|
71
|
+
// Adjacent-note interval, within the phrase only.
|
|
72
|
+
if (prevMidi !== null) {
|
|
73
|
+
const iv = midi - prevMidi;
|
|
74
|
+
histogram[iv] = (histogram[iv] ?? 0) + 1;
|
|
75
|
+
const abs = Math.abs(iv);
|
|
76
|
+
motions++;
|
|
77
|
+
if (abs > maxLeap)
|
|
78
|
+
maxLeap = abs;
|
|
79
|
+
if (abs <= 2)
|
|
80
|
+
stepCount++;
|
|
81
|
+
else if (abs <= 4)
|
|
82
|
+
skipCount++;
|
|
83
|
+
else
|
|
84
|
+
leapCount++;
|
|
85
|
+
}
|
|
86
|
+
prevMidi = midi;
|
|
48
87
|
if (note.performance.rhythmicIndex === 1) {
|
|
49
88
|
closeGroup();
|
|
50
89
|
currentGroupSize = 1;
|
|
@@ -54,10 +93,12 @@ export function computeProsody(phrases) {
|
|
|
54
93
|
}
|
|
55
94
|
}
|
|
56
95
|
}
|
|
96
|
+
if (phraseSyllables > 0)
|
|
97
|
+
cadentialMelismas.push(lastSylNotes);
|
|
57
98
|
if (phrase.divisio) {
|
|
58
99
|
// Analytic bar-importance weights, one rung per divisio in the bar-line
|
|
59
|
-
// hierarchy (the canonical table is in docs/score.md).
|
|
60
|
-
// MEASUREMENT — every divisio counts, including the virgula (`) — distinct
|
|
100
|
+
// hierarchy (the canonical table is in ../../../docs/api/score.md).
|
|
101
|
+
// These are a MEASUREMENT — every divisio counts, including the virgula (`) — distinct
|
|
61
102
|
// from phrasing.ts's DIVISIO_STRENGTH, which zeroes the virgula because it
|
|
62
103
|
// is a shaping factor, not a count.
|
|
63
104
|
const d = phrase.divisio.divisio;
|
|
@@ -92,6 +133,25 @@ export function computeProsody(phrases) {
|
|
|
92
133
|
const avgGroupSize = groupSizes.length > 0
|
|
93
134
|
? groupSizes.reduce((s, n) => s + n, 0) / groupSizes.length
|
|
94
135
|
: 0;
|
|
136
|
+
// Tessitura: mean height above the resting (final) note, in semitones.
|
|
137
|
+
const tessitura = noteCount > 0 ? midiSum / noteCount - lastMidi : null;
|
|
138
|
+
// Arch: the climb to the peak vs. the descent to the close, normalized by the
|
|
139
|
+
// total climb so a full rise-and-return reads ~+1 and a monotone rise reads ~0.
|
|
140
|
+
let arcus = null;
|
|
141
|
+
if (firstMidi !== null) {
|
|
142
|
+
const climb = peakMidi - firstMidi;
|
|
143
|
+
const archIndex = climb > 0 ? (peakMidi - lastMidi) / climb : 0;
|
|
144
|
+
arcus = { initial: firstMidi, peak: peakMidi, final: lastMidi, archIndex };
|
|
145
|
+
}
|
|
146
|
+
const intervals = {
|
|
147
|
+
histogram,
|
|
148
|
+
maxLeap,
|
|
149
|
+
leapRate: motions > 0 ? leapCount / motions : 0,
|
|
150
|
+
motus: { step: stepCount, skip: skipCount, leap: leapCount },
|
|
151
|
+
};
|
|
152
|
+
const melismaCadential = cadentialMelismas.length > 0
|
|
153
|
+
? cadentialMelismas.reduce((s, n) => s + n, 0) / cadentialMelismas.length
|
|
154
|
+
: 0;
|
|
95
155
|
return {
|
|
96
156
|
phraseCount,
|
|
97
157
|
noteCount,
|
|
@@ -100,6 +160,10 @@ export function computeProsody(phrases) {
|
|
|
100
160
|
ambitus: noteRange?.span ?? null,
|
|
101
161
|
melismaRatio: syllableCount > 0 ? noteCount / syllableCount : 0,
|
|
102
162
|
melismaByPhrase,
|
|
163
|
+
melismaCadential,
|
|
164
|
+
tessitura,
|
|
165
|
+
intervals,
|
|
166
|
+
arcus,
|
|
103
167
|
ictusRate: noteCount > 0 ? ictusCount / noteCount : 0,
|
|
104
168
|
rhythmicProfile: {
|
|
105
169
|
arsic: arsicCount,
|