tonus 0.1.4 → 0.1.6
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 +143 -96
- package/CHANGELOG.md +62 -0
- package/README.md +7 -9
- package/dist/data/am.d.ts +5 -0
- package/dist/data/am.js +14299 -0
- package/dist/data/office-monastic.d.ts +3 -0
- package/dist/data/office-monastic.js +6956 -0
- package/dist/data/office-psalms-monastic.d.ts +3 -0
- package/dist/data/office-psalms-monastic.js +21 -0
- package/dist/data/{office-psalms.d.ts → office-psalms-roman.d.ts} +1 -1
- package/dist/data/{office-psalms.js → office-psalms-roman.js} +3 -3
- package/dist/engines/cal/calendar.js +16 -1
- package/dist/engines/cal/date.js +5 -0
- package/dist/engines/chant/chant.js +2 -0
- package/dist/{data → engines/chant/data}/compline.d.ts +1 -1
- package/dist/{data → engines/chant/data}/masses.d.ts +1 -1
- package/dist/{data → engines/chant/data}/masses.js +1 -1
- package/dist/{data → engines/chant/data}/prime.d.ts +1 -1
- package/dist/{data → engines/chant/data}/prime.js +1 -1
- package/dist/engines/chant/hour.js +37 -22
- package/dist/engines/chant/intone.js +6 -1
- package/dist/engines/chant/ordinary.js +1 -1
- package/dist/engines/chant/psalm.d.ts +4 -4
- package/dist/engines/chant/psalm.js +6 -4
- package/dist/engines/chant/types.d.ts +5 -1
- package/dist/engines/harmonia/api.js +11 -0
- package/dist/engines/harmonia/data/doctrines.js +27 -1
- package/dist/engines/imprint.js +7 -1
- package/dist/engines/planet/position.js +51 -5
- package/dist/engines/score/articulation.js +60 -3
- package/dist/engines/score/cadence.js +3 -3
- package/dist/engines/score/emitters/midi.js +6 -2
- package/dist/engines/score/infer.js +13 -1
- package/dist/engines/score/ir.d.ts +2 -1
- package/dist/engines/score/ir.js +54 -12
- package/dist/engines/score/modulation.js +2 -2
- package/dist/engines/score/parse.js +16 -8
- package/dist/engines/score/phrasing.js +26 -0
- package/dist/engines/score/prosody.js +5 -0
- package/dist/engines/score/tabula.d.ts +2 -0
- package/dist/engines/score/tabula.js +1 -0
- package/dist/engines/score/types.d.ts +11 -2
- package/dist/engines/temper/api.js +1 -1
- package/dist/engines/temper/data/guido.d.ts +3 -2
- package/dist/engines/temper/data/guido.js +30 -25
- package/dist/engines/temper/data/modes.js +3 -0
- package/dist/{data → engines/temper/data}/tones.js +1 -1
- package/dist/engines/temper/gabc.js +25 -2
- package/dist/engines/temper/guido.d.ts +3 -2
- package/dist/engines/temper/interval.js +12 -0
- package/dist/engines/temper/modality.js +5 -4
- package/dist/engines/temper/scale.js +33 -2
- package/dist/engines/temper/step.d.ts +2 -2
- package/dist/index.d.ts +3 -3
- package/package.json +2 -1
- package/dist/data/office.d.ts +0 -12
- package/dist/data/office.js +0 -13052
- /package/dist/{data → engines/chant/data}/compline.js +0 -0
- /package/dist/{data → engines/temper/data}/tones.d.ts +0 -0
|
@@ -1,35 +1,92 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
2
|
// engines/score/articulation — note-level articulation profiles
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
|
+
// The pondus ("weight") tables. Each GABC performance mark — episema, quilisma,
|
|
5
|
+
// liquescent, strophicus, oriscus, ictus — carries a weight delta and a
|
|
6
|
+
// duration delta that the parser folds into a note's rhythmicShape (see the
|
|
7
|
+
// tanh compressor in parse.ts). The signs encode the semiological reading of
|
|
8
|
+
// the mark [biblio: cardine-semiology], and the durational values the Solesmes
|
|
9
|
+
// rhythmic tradition [biblio: desrocquettes-values, liber-usualis]: POSITIVE
|
|
10
|
+
// lengthens/stresses, NEGATIVE lightens/shortens. So an episema lengthens
|
|
11
|
+
// (+0.9 duration), a liquescent is a passing lightening (−0.4 weight), an
|
|
12
|
+
// uppercase-syllable note is de-stressed (−0.7).
|
|
13
|
+
//
|
|
14
|
+
// Four profiles select how strongly those rules speak, from a semiological
|
|
15
|
+
// restraint to full Solesmes-rule fidelity:
|
|
16
|
+
// restrained — Cardine's semiological reading: rules present but soft-spoken.
|
|
17
|
+
// balanced — the default; BASE_WEIGHTS unaltered.
|
|
18
|
+
// expressive — rules pushed (stronger ictus and accent, wider contour).
|
|
19
|
+
// strict — maximal Solesmes-rule fidelity (episema to a full double, a
|
|
20
|
+
// sharper quilisma). The differences live in the per-profile
|
|
21
|
+
// weight overrides below.
|
|
22
|
+
// The curve params (weightBase/Gain/Saturation) shape the tanh knee, not the
|
|
23
|
+
// marks; they are documented at the profile block. These magnitudes are tuned
|
|
24
|
+
// by ear, not taken from a table — the sources fix the direction, not the value.
|
|
4
25
|
const BASE_WEIGHTS = {
|
|
26
|
+
// Ictus — the rhythmic footfall (parse.ts's three-rule assignment): a stress
|
|
27
|
+
// and a slight broadening.
|
|
5
28
|
ictusWeight: 0.9,
|
|
6
29
|
ictusDuration: 0.35,
|
|
30
|
+
// Episema — the horizontal episema is the Solesmes lengthening mark; the note
|
|
31
|
+
// is broadened but only lightly stressed. Double episema broadens further.
|
|
7
32
|
episemaWeight: 0.6,
|
|
8
33
|
episemaDuration: 0.9,
|
|
9
34
|
episemaDoubleDuration: 0.6,
|
|
35
|
+
// Strophicus — repeated notes (distropha/tristropha); a soft repercussion
|
|
36
|
+
// that broadens; the triple adds duration.
|
|
10
37
|
strophicusWeight: 0.4,
|
|
11
38
|
strophicusDuration: 0.8,
|
|
12
39
|
strophicusTripleDuration: 0.6,
|
|
40
|
+
// Quilisma — the mark itself is a light, tremulous passing note (−0.3), but
|
|
41
|
+
// the rule is that the *preceding* note receives the stress and lengthening.
|
|
42
|
+
// Hence prev is positive and the quilisma negative — not a sign error.
|
|
13
43
|
quilismaPrevWeight: 0.6,
|
|
14
44
|
quilismaWeight: -0.3,
|
|
45
|
+
// Liquescent — a diminished, half-voiced note easing a consonant cluster:
|
|
46
|
+
// lighter and shorter.
|
|
15
47
|
liquescentWeight: -0.4,
|
|
16
48
|
liquescentDuration: -0.35,
|
|
49
|
+
// Initio — the first note of a group/melisma gets a gentle onset stress.
|
|
17
50
|
initioWeight: 0.6,
|
|
18
51
|
initioMelismaWeight: 0.3,
|
|
19
52
|
initioMelismaDuration: 0.1,
|
|
53
|
+
// Accent — a tonic word-accent lands a stress.
|
|
20
54
|
accentWeight: 0.4,
|
|
55
|
+
// Uppercase — an emphasized-capital syllable in GABC is conventionally the
|
|
56
|
+
// quiet incipit letter of a chant, so it is de-stressed and slightly quickened.
|
|
21
57
|
uppercaseWeight: -0.7,
|
|
22
58
|
uppercaseDuration: -0.2,
|
|
59
|
+
// Repercussion (pressus) — like the quilisma, the weight falls on the note
|
|
60
|
+
// *before* the repercussed unison; the oriscus element itself is light.
|
|
23
61
|
repercussionPrevWeight: 0.5,
|
|
24
62
|
repercussionPrevDuration: 0.4,
|
|
25
63
|
repercussionOriscusWeight: -0.5,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
64
|
+
// Oriscus — a soft, light note taken slightly faster (accelerando); its
|
|
65
|
+
// rhythmic support sits on the preceding note.
|
|
66
|
+
oriscusWeight: -0.3,
|
|
67
|
+
oriscusDuration: -0.15,
|
|
68
|
+
oriscusPrevWeight: 0.3,
|
|
69
|
+
// Break (a neume-internal division `!`/`/`) marks a fresh onset → a stress;
|
|
70
|
+
// dash (a bare context, e.g. an unaccented syllable) lightens and quickens.
|
|
29
71
|
breakWeight: 0.6,
|
|
30
72
|
dashWeight: -0.8,
|
|
31
73
|
dashDuration: -0.2,
|
|
32
74
|
};
|
|
75
|
+
// Curve params (same fields on every profile below). They shape how the summed
|
|
76
|
+
// weight becomes a velocity, not what each mark contributes:
|
|
77
|
+
// weightBase — the velocity a note centres on; the tanh term swings ±gain
|
|
78
|
+
// around it, so output sits roughly in [base−gain, base+gain].
|
|
79
|
+
// weightGain — how loudly the rules speak: scales the tanh output (also
|
|
80
|
+
// multiplied by ruleGain). Rises restrained→expressive as rules
|
|
81
|
+
// gain authority (2.3 → 3 → 3.5), strict slightly under expressive.
|
|
82
|
+
// weightSaturation — the tanh knee divisor: larger = later compression, so louder
|
|
83
|
+
// peaks survive; expressive lowers it (2.8) for a sharper dynamic.
|
|
84
|
+
// durationBase/Gain/Min/Max — the same shape applied to duration deltas, clamped.
|
|
85
|
+
// ruleGain — global authority of the mark rules vs. positional shaping.
|
|
86
|
+
// contourScale — how much melodic contour (rise/fall) colours velocity.
|
|
87
|
+
// neumeArch/durArch— the within-neume rise-and-fall (see phrasing.ts's arch).
|
|
88
|
+
// ictusBoost — a final multiplier on ictus notes.
|
|
89
|
+
// All tuned by ear; the ordering across profiles is the doctrine, the values are not.
|
|
33
90
|
const ARTICULATION_PROFILES = {
|
|
34
91
|
restrained: {
|
|
35
92
|
weights: { ...BASE_WEIGHTS, accentWeight: 0.25, ictusDuration: 0.2 },
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// Cadence formulae run four to ten notes
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Cadence formulae run four to ten notes [biblio: homan-cadence, p. xiii]. Take
|
|
2
|
+
// a window at the upper end so the longest figures fit, with room for the
|
|
3
|
+
// approach; tail-matching ignores the extra leading notes.
|
|
4
4
|
const WINDOW = 8;
|
|
5
5
|
/** Last up-to-WINDOW notes of a phrase, resolution note last. */
|
|
6
6
|
function phraseFinalWindow(phrase) {
|
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
// Divisio → rest duration (in the same beat units as note durations)
|
|
2
|
-
//
|
|
1
|
+
// Divisio → rest duration (in the same beat units as note durations); a phrase's
|
|
2
|
+
// terminal divisio becomes a rest of this length. This is the *durational*
|
|
3
|
+
// reading of the divisio hierarchy (canonical table in docs/score.md), distinct
|
|
4
|
+
// from prosody.ts's analytic weights and phrasing.ts's shaping strengths — three
|
|
5
|
+
// readings of the same bar-lines, each for its own purpose. Values mirror
|
|
6
|
+
// DIVISIO_DURATIONS in parse.ts (the source of the fractional beat lengths).
|
|
3
7
|
const DIVISIO_REST = {
|
|
4
8
|
",": 0.54, "`": 0.33, ";": 0.8, ":": 1.1, "::": 1.8,
|
|
5
9
|
};
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { OFFICE_LABELS } from "../chant/types.js";
|
|
2
2
|
import { MODES } from "../temper/modes.js";
|
|
3
|
+
// Identify an ordinary chant by its opening words. Two movements have a second,
|
|
4
|
+
// interior incipit because they are conventionally intoned from their second
|
|
5
|
+
// phrase: the Gloria's "Et in terra pax" (the celebrant sings "Gloria in
|
|
6
|
+
// excelsis") and the Credo's "Patrem omnipotentem" ("Credo in unum Deum").
|
|
3
7
|
const ORDINARY_INCIPITS = [
|
|
4
8
|
[/^kyrie/i, "ky"],
|
|
5
9
|
[/^gloria/i, "gl"],
|
|
@@ -38,6 +42,9 @@ export function inferMode(ir) {
|
|
|
38
42
|
}
|
|
39
43
|
if (midis.length === 0)
|
|
40
44
|
return undefined;
|
|
45
|
+
// The finalis is the last note of the chant — the fundamental assumption of
|
|
46
|
+
// modal analysis: a chant comes to rest on its mode's final [biblio:
|
|
47
|
+
// liber-usualis]. That pitch class picks the maneria (mode pair).
|
|
41
48
|
const finalisPc = ((midis[midis.length - 1] % 12) + 12) % 12;
|
|
42
49
|
const candidates = [];
|
|
43
50
|
for (const [num, data] of MODES) {
|
|
@@ -48,7 +55,12 @@ export function inferMode(ir) {
|
|
|
48
55
|
return undefined;
|
|
49
56
|
if (candidates.length === 1)
|
|
50
57
|
return candidates[0];
|
|
51
|
-
//
|
|
58
|
+
// Within a maneria the authentic mode ranges a fifth-and-more ABOVE the final,
|
|
59
|
+
// the plagal one straddles it (roughly a fourth below to a fifth above) [biblio:
|
|
60
|
+
// sunol-textbook]. So the mean pitch's height above the final separates them.
|
|
61
|
+
// The thresholds are asymmetric and tuned: a melody must sit a clear +3
|
|
62
|
+
// semitones above the final to read authentic, but only dip −1 below to read
|
|
63
|
+
// plagal (plagal melodies dip under the final; authentic ones rarely do).
|
|
52
64
|
const finalisStep = midis[midis.length - 1];
|
|
53
65
|
const mean = midis.reduce((s, v) => s + v, 0) / midis.length;
|
|
54
66
|
const offset = mean - finalisStep;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { Score, ParseResult } from "./types.js";
|
|
1
|
+
import type { CompoundBeat, RhythmicType, Score, ParseResult } from "./types.js";
|
|
2
2
|
import type { Scale } from "../temper/scale.js";
|
|
3
|
+
export declare function classifyRhythmicType(beats: CompoundBeat[]): RhythmicType;
|
|
3
4
|
export declare function buildIR(parsed: ParseResult, chant: Score["chant"], scale: Scale): Score;
|
|
4
5
|
//# sourceMappingURL=ir.d.ts.map
|
package/dist/engines/score/ir.js
CHANGED
|
@@ -24,13 +24,13 @@ function rawToNote(raw, scale) {
|
|
|
24
24
|
liquescent: raw.liquescent,
|
|
25
25
|
strophicus: raw.strophicus,
|
|
26
26
|
oriscus: raw.oriscus,
|
|
27
|
-
|
|
27
|
+
mora: raw.mora,
|
|
28
28
|
weight: raw.weight,
|
|
29
29
|
},
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
|
-
// The salicus ictus note (its second-to-last ascending note) is prolonged
|
|
33
|
-
//
|
|
32
|
+
// The salicus ictus note (its second-to-last ascending note) is prolonged
|
|
33
|
+
// [biblio: sunol-textbook, Ch. V]. Modest, in the spirit of an episema.
|
|
34
34
|
const SALICUS_PROLONGATION = 1.3;
|
|
35
35
|
function makeSyllable(lyric, notes) {
|
|
36
36
|
const neume = classifyNeume(notes);
|
|
@@ -46,16 +46,16 @@ function partitionByIctus(annotated) {
|
|
|
46
46
|
let currentIctusMidi;
|
|
47
47
|
const closeGroup = (items, ictusMidi) => {
|
|
48
48
|
const neumeTypes = new Set();
|
|
49
|
-
let
|
|
49
|
+
let hasDoubleMora = false;
|
|
50
50
|
for (const a of items) {
|
|
51
51
|
neumeTypes.add(a.neumeType);
|
|
52
|
-
if (a.note.context.
|
|
53
|
-
|
|
52
|
+
if (a.note.context.mora === 2)
|
|
53
|
+
hasDoubleMora = true;
|
|
54
54
|
}
|
|
55
55
|
return {
|
|
56
56
|
notes: items.map((a) => a.note),
|
|
57
57
|
neumeTypes,
|
|
58
|
-
|
|
58
|
+
hasDoubleMora,
|
|
59
59
|
ictusMidi,
|
|
60
60
|
shape: "arsic",
|
|
61
61
|
};
|
|
@@ -85,7 +85,7 @@ function classifyGroup(group, prev, apexMidi) {
|
|
|
85
85
|
// regardless of melodic context.
|
|
86
86
|
if (group.neumeTypes.has("salicus"))
|
|
87
87
|
return "arsic";
|
|
88
|
-
if (group.
|
|
88
|
+
if (group.hasDoubleMora && group.neumeTypes.has("clivis"))
|
|
89
89
|
return "thetic";
|
|
90
90
|
const groupIctusMidi = group.ictusMidi ?? group.notes[0].pitch.midi;
|
|
91
91
|
// Rule 1: incise unity — at or after the apex, everything thetic.
|
|
@@ -111,14 +111,18 @@ function classifyGroup(group, prev, apexMidi) {
|
|
|
111
111
|
// Tie-breaker: alternate from previous.
|
|
112
112
|
return prev.shape === "arsic" ? "thetic" : "arsic";
|
|
113
113
|
}
|
|
114
|
+
// Classify each compound beat, stamp its notes, and return the beat sequence —
|
|
115
|
+
// the A/T shape of the incise, which the rhythmic-type classifier and (later)
|
|
116
|
+
// the chironomy renderer both read.
|
|
114
117
|
function classifyCompoundBeats(annotated) {
|
|
115
118
|
if (annotated.length === 0)
|
|
116
|
-
return;
|
|
119
|
+
return [];
|
|
117
120
|
const groups = partitionByIctus(annotated);
|
|
118
121
|
// Apex = highest-pitched ictus in the incise.
|
|
119
122
|
const apexMidi = annotated
|
|
120
123
|
.filter((a) => a.note.context.ictus)
|
|
121
124
|
.reduce((max, a) => Math.max(max, a.note.pitch.midi), -Infinity);
|
|
125
|
+
const beats = [];
|
|
122
126
|
for (let gi = 0; gi < groups.length; gi++) {
|
|
123
127
|
const group = groups[gi];
|
|
124
128
|
const prev = gi > 0 ? groups[gi - 1] : null;
|
|
@@ -127,7 +131,44 @@ function classifyCompoundBeats(annotated) {
|
|
|
127
131
|
group.notes[ni].performance.rhythmicShape = group.shape;
|
|
128
132
|
group.notes[ni].performance.rhythmicIndex = ni + 1;
|
|
129
133
|
}
|
|
134
|
+
beats.push({ shape: group.shape, noteCount: group.notes.length });
|
|
130
135
|
}
|
|
136
|
+
return beats;
|
|
137
|
+
}
|
|
138
|
+
// Le Guennant's rhythmic types (IV–VIII) from the beat sequence — how the incise's
|
|
139
|
+
// compound beats chain [biblio: carroll-chironomy, pp. 22–26]. A type is emitted
|
|
140
|
+
// only when the sequence genuinely fits; ambiguous shapes stay null (a wrong
|
|
141
|
+
// analytic label is worse than none). Contraction (Type VIII) is Suñol's local
|
|
142
|
+
// reading [biblio: sunol-textbook]: a thesis immediately followed by an arsis
|
|
143
|
+
// mid-incise means two simple rhythms overlap at a shared ictus.
|
|
144
|
+
export function classifyRhythmicType(beats) {
|
|
145
|
+
if (beats.length < 2)
|
|
146
|
+
return null; // a lone beat has no chaining; needs ≥ A–T
|
|
147
|
+
const seq = beats.map((b) => b.shape);
|
|
148
|
+
if (seq[0] !== "arsic")
|
|
149
|
+
return null; // an incise never begins thetic (Carroll p. 43)
|
|
150
|
+
const hasSeam = seq.some((s, i) => i > 0 && seq[i - 1] === "thetic" && s === "arsic");
|
|
151
|
+
if (!hasSeam) {
|
|
152
|
+
// A single rise-and-fall: arses, then theses, no interior thesis→arsis.
|
|
153
|
+
const arses = seq.filter((s) => s === "arsic").length;
|
|
154
|
+
const theses = seq.length - arses;
|
|
155
|
+
if (theses === 0)
|
|
156
|
+
return null; // all arsic, no resolution
|
|
157
|
+
if (arses === 1 && theses === 1)
|
|
158
|
+
return "IV"; // A–T
|
|
159
|
+
if (arses >= 2 && theses === 1)
|
|
160
|
+
return "V"; // A–A…–T
|
|
161
|
+
if (arses === 1 && theses >= 2)
|
|
162
|
+
return "VI"; // A–T–T…
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
// There is at least one thesis→arsis seam, so the incise chains multiple simple
|
|
166
|
+
// rhythms. Strict alternation A–T–A–T(…) is Carroll's Type VII; any other seam
|
|
167
|
+
// pattern is a contraction (Suñol) — Carroll's Type VIII.
|
|
168
|
+
const alternating = seq.every((s, i) => s === (i % 2 === 0 ? "arsic" : "thetic"));
|
|
169
|
+
if (alternating && seq.length >= 4)
|
|
170
|
+
return "VII";
|
|
171
|
+
return "VIII";
|
|
131
172
|
}
|
|
132
173
|
function applyCompoundBeats(phrases) {
|
|
133
174
|
for (const phrase of phrases) {
|
|
@@ -137,12 +178,13 @@ function applyCompoundBeats(phrases) {
|
|
|
137
178
|
annotated.push({ note, neumeType: syl.neume.type });
|
|
138
179
|
}
|
|
139
180
|
}
|
|
140
|
-
classifyCompoundBeats(annotated);
|
|
181
|
+
phrase.beats = classifyCompoundBeats(annotated);
|
|
182
|
+
phrase.rhythmicType = classifyRhythmicType(phrase.beats);
|
|
141
183
|
}
|
|
142
184
|
}
|
|
143
185
|
export function buildIR(parsed, chant, scale) {
|
|
144
186
|
const phrases = [];
|
|
145
|
-
let currentPhrase = { syllables: [] };
|
|
187
|
+
let currentPhrase = { syllables: [], beats: [], rhythmicType: null };
|
|
146
188
|
let currentNotes = [];
|
|
147
189
|
let currentLyric = null;
|
|
148
190
|
for (const event of parsed.events) {
|
|
@@ -167,7 +209,7 @@ export function buildIR(parsed, chant, scale) {
|
|
|
167
209
|
}
|
|
168
210
|
currentPhrase.divisio = event;
|
|
169
211
|
phrases.push(currentPhrase);
|
|
170
|
-
currentPhrase = { syllables: [] };
|
|
212
|
+
currentPhrase = { syllables: [], beats: [], rhythmicType: null };
|
|
171
213
|
}
|
|
172
214
|
}
|
|
173
215
|
if (currentLyric !== null && currentNotes.length > 0) {
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { computeModalAffinity } from "../temper/modality.js";
|
|
2
2
|
// How much a foreign mode must outscore the home mode (in normalised affinity)
|
|
3
3
|
// before a phrase counts as leaning away. Calibrated against Suñol's worked
|
|
4
|
-
// examples: at 0.25 the modulations he names in Christus
|
|
5
|
-
// register, while incidental
|
|
4
|
+
// examples [biblio: sunol-textbook]: at 0.25 the modulations he names in Christus
|
|
5
|
+
// resurgens (to mode 3) register, while incidental colouring below that does not.
|
|
6
6
|
const MARGIN = 0.25;
|
|
7
7
|
/** The pitch-class distribution of one phrase's notes (fractions summing to 1). */
|
|
8
8
|
function phrasePcDistribution(phrase) {
|
|
@@ -5,6 +5,11 @@ const DEFAULT_OPTIONS = {
|
|
|
5
5
|
oct: 3,
|
|
6
6
|
useVowelAccent: true,
|
|
7
7
|
};
|
|
8
|
+
// Per-clef diatonic-step offset. A GABC clef names the staff line it sits on
|
|
9
|
+
// (c1–c4, f1–f4; the `b` variants also declare a flat), which fixes which pitch
|
|
10
|
+
// the letters a–m read as. The value is subtracted from the letter's position
|
|
11
|
+
// when anchoring it onto the gamut (see `pos` below), so the same letter reads
|
|
12
|
+
// higher or lower depending on the clef in force.
|
|
8
13
|
const CLEF_OFFSETS = new Map([
|
|
9
14
|
["c1", -3],
|
|
10
15
|
["c2", -1],
|
|
@@ -84,7 +89,7 @@ function parseNeume(notation, context) {
|
|
|
84
89
|
let isLiquescent = false;
|
|
85
90
|
let isStrophicus = false;
|
|
86
91
|
let isOriscus = false;
|
|
87
|
-
let
|
|
92
|
+
let mora = 0;
|
|
88
93
|
// Dash prefix (weak note)
|
|
89
94
|
if (token[0] === "-") {
|
|
90
95
|
token = token.slice(1);
|
|
@@ -143,16 +148,19 @@ function parseNeume(notation, context) {
|
|
|
143
148
|
durWeight += weights.ictusDuration;
|
|
144
149
|
ictus = true;
|
|
145
150
|
}
|
|
146
|
-
//
|
|
151
|
+
// Mora vocis — the GABC dot '.' lengthens THIS note (a held note before a
|
|
152
|
+
// pause). A double dot '..' is the double mora (a stronger lengthening at a
|
|
153
|
+
// major cadence); it adds to this note's duration, not the neume's first
|
|
154
|
+
// note — the dots belong to the note that carries them. (The horizontal
|
|
155
|
+
// episema is '_', handled with the ictus markers above.)
|
|
147
156
|
if (modifiers.includes(".")) {
|
|
148
157
|
w += weights.episemaWeight;
|
|
149
158
|
durWeight += weights.episemaDuration;
|
|
150
159
|
ictus = true;
|
|
151
|
-
|
|
160
|
+
mora = 1;
|
|
152
161
|
if (modifiers.includes("..")) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
intermed[0]._durWeight += weights.episemaDoubleDuration;
|
|
162
|
+
mora = 2;
|
|
163
|
+
durWeight += weights.episemaDoubleDuration;
|
|
156
164
|
}
|
|
157
165
|
}
|
|
158
166
|
// Strophicus (ss or vv = repeated/tremolo notes)
|
|
@@ -233,7 +241,7 @@ function parseNeume(notation, context) {
|
|
|
233
241
|
liquescent: isLiquescent,
|
|
234
242
|
strophicus: isStrophicus,
|
|
235
243
|
oriscus: isOriscus,
|
|
236
|
-
|
|
244
|
+
mora,
|
|
237
245
|
_weight: w,
|
|
238
246
|
_durWeight: durWeight,
|
|
239
247
|
});
|
|
@@ -288,7 +296,7 @@ function parseNeume(notation, context) {
|
|
|
288
296
|
liquescent: note.liquescent,
|
|
289
297
|
strophicus: note.strophicus,
|
|
290
298
|
oriscus: note.oriscus,
|
|
291
|
-
|
|
299
|
+
mora: note.mora,
|
|
292
300
|
};
|
|
293
301
|
});
|
|
294
302
|
return result;
|
|
@@ -62,6 +62,9 @@ export function buildPhrasing(type = "lyrical", options = {}) {
|
|
|
62
62
|
...(options.overrides ?? {}),
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
|
+
// Authentic modes reach a fifth or more above the final and pull harder toward
|
|
66
|
+
// their tenor/reciting note; plagal modes straddle the final and pull less
|
|
67
|
+
// [biblio: sunol-textbook]. These nudge the tenor and cadence gains accordingly.
|
|
65
68
|
const MODE_TYPE_TENOR_MULTIPLIER = {
|
|
66
69
|
authentic: 1.08,
|
|
67
70
|
plagal: 0.92,
|
|
@@ -70,12 +73,20 @@ const MODE_TYPE_CADENCE_MULTIPLIER = {
|
|
|
70
73
|
authentic: 1.05,
|
|
71
74
|
plagal: 0.95,
|
|
72
75
|
};
|
|
76
|
+
// A more melismatic mode leans into its cadences; a syllabic one under-plays them.
|
|
73
77
|
const MODE_TENDENCY_CADENCE_MULTIPLIER = {
|
|
74
78
|
melismatic: 1.06,
|
|
75
79
|
neumatic: 1.0,
|
|
76
80
|
syllabic: 0.96,
|
|
77
81
|
neutral: 1.0,
|
|
78
82
|
};
|
|
83
|
+
// The modal ethos → performance mapping. Each mode carries a mood epithet (the
|
|
84
|
+
// Niedermeyer & d'Ortigue tradition [biblio: niedermeyer-ortigue], stored on
|
|
85
|
+
// ModeProfile in modes.ts); these translate that mood into small shaping deltas.
|
|
86
|
+
// The interpretation is tonus's own — the epithets are sourced, these numbers
|
|
87
|
+
// are a tuned editorial gesture, deliberately small. Note the coherence: sadder
|
|
88
|
+
// moods drop the velocity floor AND deepen the phrase curve (below), so they
|
|
89
|
+
// read quieter and more arched; brighter moods do the reverse.
|
|
79
90
|
const MODE_MOOD_BASE_VELOCITY_DELTA = {
|
|
80
91
|
neutral: 0,
|
|
81
92
|
serious: -0.01,
|
|
@@ -144,9 +155,17 @@ const DURATION_ARCH_FACTOR = 0.2;
|
|
|
144
155
|
const DURATION_MIN = 0.2;
|
|
145
156
|
const DURATION_MAX = 4.0;
|
|
146
157
|
const TENOR_GAIN = 0.05;
|
|
158
|
+
// Tenor pull falls linearly with pitch-class distance from the reciting tone and
|
|
159
|
+
// reaches zero at 6 semitones (a tritone) — the far side of the octave, past
|
|
160
|
+
// which a note no longer reads as gravitating to the tenor.
|
|
147
161
|
const TENOR_DISTANCE_DIVISOR = 6;
|
|
148
162
|
const CADENCE_VELOCITY_FACTOR = 0.5;
|
|
149
163
|
const CADENCE_DURATION_FACTOR = 0.6;
|
|
164
|
+
// The phrasing-side reading of the divisio (bar-line) hierarchy. This is the same
|
|
165
|
+
// bar hierarchy tabulated canonically in docs/score.md and weighted for analysis
|
|
166
|
+
// by prosody.ts's cadenceWeight ladder — but here the weights differ on purpose:
|
|
167
|
+
// this is a *shaping* factor, so the virgula (`) gets 0 (a breath, no cadential
|
|
168
|
+
// stress), where the analytic ladder still counts it. Do not unify the three.
|
|
150
169
|
const DIVISIO_STRENGTH = {
|
|
151
170
|
"::": 1.0,
|
|
152
171
|
":": 0.7,
|
|
@@ -214,8 +233,15 @@ export function applyPhrasing(events, profile, tenorPc) {
|
|
|
214
233
|
const arsis = note.context.weight;
|
|
215
234
|
const arsisRelative = (arsis - minArsis) / arsisSpan;
|
|
216
235
|
const contourRelative = (note.pitch.midi - minStep) / stepSpan;
|
|
236
|
+
// A raised-cosine (Hann) window over the phrase: 0 at the ends, 1 at the
|
|
237
|
+
// middle, so each phrase swells and subsides — the Solesmes grand rythme,
|
|
238
|
+
// the phrase-arch [biblio: mocquereau-nombre]. `t` is the note's fractional
|
|
239
|
+
// position through the phrase.
|
|
217
240
|
const t = (order + 0.5) / (noteEntries.length + 0.0001);
|
|
218
241
|
const arch = 0.5 - 0.5 * Math.cos(2 * Math.PI * t);
|
|
242
|
+
// Blend the note's intrinsic arsic weight against its positional arch;
|
|
243
|
+
// `curve` is the mix knob (0 = follow the note's own weight, 1 = follow the
|
|
244
|
+
// phrase arch). The result is then re-centred on 0.5 and spread by velSpread.
|
|
219
245
|
let velocity = arsisRelative * (1 - profile.curve) + arch * profile.curve;
|
|
220
246
|
velocity += (contourRelative - VELOCITY_CENTER) * profile.contourVel;
|
|
221
247
|
velocity =
|
|
@@ -55,6 +55,11 @@ export function computeProsody(phrases) {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
if (phrase.divisio) {
|
|
58
|
+
// Analytic bar-importance weights, one rung per divisio in the bar-line
|
|
59
|
+
// hierarchy (the canonical table is in docs/score.md). These are a
|
|
60
|
+
// MEASUREMENT — every divisio counts, including the virgula (`) — distinct
|
|
61
|
+
// from phrasing.ts's DIVISIO_STRENGTH, which zeroes the virgula because it
|
|
62
|
+
// is a shaping factor, not a count.
|
|
58
63
|
const d = phrase.divisio.divisio;
|
|
59
64
|
if (d === "::") {
|
|
60
65
|
cadDist.doubleBar++;
|
|
@@ -42,6 +42,8 @@ export interface ChantTabulaRow {
|
|
|
42
42
|
liquescent: boolean;
|
|
43
43
|
strophicus: boolean;
|
|
44
44
|
oriscus: boolean;
|
|
45
|
+
/** Mora vocis count: 0 none, 1 dot '.', 2 double dot '..' (a stronger cadential hold). */
|
|
46
|
+
mora: 0 | 1 | 2;
|
|
45
47
|
divisio: string | null;
|
|
46
48
|
/** Index into score.cadences[] when this note forms a cadence; null otherwise. */
|
|
47
49
|
cadenceRef: number | null;
|
|
@@ -104,6 +104,7 @@ export function computeTabula(ir, options = {}) {
|
|
|
104
104
|
liquescent: n.context.liquescent,
|
|
105
105
|
strophicus: n.context.strophicus,
|
|
106
106
|
oriscus: n.context.oriscus,
|
|
107
|
+
mora: n.context.mora,
|
|
107
108
|
divisio: a.divisio,
|
|
108
109
|
cadenceRef: cadenceRefByPos.get(`${a.phraseIndex}:${a.syllableIndex}:${a.noteIndex}`) ?? null,
|
|
109
110
|
role: n.step.role,
|
|
@@ -3,6 +3,11 @@ import type { Step } from "../temper/step.js";
|
|
|
3
3
|
import type { NeumeShape } from "../temper/neume.js";
|
|
4
4
|
import type { OfficeCode, OrdinaryCode } from "../chant/types.js";
|
|
5
5
|
export type ArsisThesis = "arsic" | "thetic";
|
|
6
|
+
export type RhythmicType = "IV" | "V" | "VI" | "VII" | "VIII" | null;
|
|
7
|
+
export interface CompoundBeat {
|
|
8
|
+
shape: ArsisThesis;
|
|
9
|
+
noteCount: number;
|
|
10
|
+
}
|
|
6
11
|
export interface Performance {
|
|
7
12
|
velocity: number;
|
|
8
13
|
duration: number;
|
|
@@ -21,7 +26,7 @@ export interface Context {
|
|
|
21
26
|
liquescent: boolean;
|
|
22
27
|
strophicus: boolean;
|
|
23
28
|
oriscus: boolean;
|
|
24
|
-
|
|
29
|
+
mora: 0 | 1 | 2;
|
|
25
30
|
weight: number;
|
|
26
31
|
}
|
|
27
32
|
export interface Note {
|
|
@@ -123,7 +128,7 @@ export interface ParsedNote {
|
|
|
123
128
|
liquescent: boolean;
|
|
124
129
|
strophicus: boolean;
|
|
125
130
|
oriscus: boolean;
|
|
126
|
-
|
|
131
|
+
mora: 0 | 1 | 2;
|
|
127
132
|
}
|
|
128
133
|
export interface RestEvent {
|
|
129
134
|
type: "rest";
|
|
@@ -151,6 +156,10 @@ export interface Syllable {
|
|
|
151
156
|
export interface Phrase {
|
|
152
157
|
syllables: Syllable[];
|
|
153
158
|
divisio?: RestEvent;
|
|
159
|
+
/** The phrase's compound beats in order — the A/T sequence of the incise. */
|
|
160
|
+
beats: CompoundBeat[];
|
|
161
|
+
/** Le Guennant/Carroll rhythmic type of the incise; null if none fits. */
|
|
162
|
+
rhythmicType: RhythmicType;
|
|
154
163
|
}
|
|
155
164
|
export interface Score {
|
|
156
165
|
chant: {
|
|
@@ -8,7 +8,7 @@ import { classifyInterval } from "./interval.js";
|
|
|
8
8
|
import { buildNeume } from "./neume.js";
|
|
9
9
|
import { buildGamut } from "./gamut.js";
|
|
10
10
|
import { getMode } from "./modes.js";
|
|
11
|
-
import { getTone, getDifferentia } from "
|
|
11
|
+
import { getTone, getDifferentia } from "./data/tones.js";
|
|
12
12
|
function resolveOpts(input) {
|
|
13
13
|
if (!input)
|
|
14
14
|
return {};
|
|
@@ -1,11 +1,12 @@
|
|
|
1
|
+
import type { Finger, Region } from "../step.js";
|
|
1
2
|
export interface GuidonianVariant {
|
|
2
3
|
hexachord: "durum" | "naturale" | "molle";
|
|
3
4
|
solmization: "UT" | "RE" | "MI" | "FA" | "SOL" | "LA";
|
|
4
5
|
}
|
|
5
6
|
export interface GuidonianEntry {
|
|
6
7
|
hand: {
|
|
7
|
-
finger:
|
|
8
|
-
region:
|
|
8
|
+
finger: Finger;
|
|
9
|
+
region: Region;
|
|
9
10
|
};
|
|
10
11
|
name: [string, string];
|
|
11
12
|
variants: GuidonianVariant[];
|
|
@@ -1,29 +1,34 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// The hand loci follow the CANONICAL Guidonian spiral, not a linear fill:
|
|
2
|
+
// begin at the thumb tip, come down the thumb, cross the finger BASES
|
|
3
|
+
// index→little, climb the little finger, return across the finger TIPS
|
|
4
|
+
// little→index, descend the index, then spiral inward; ee (76) floats above the
|
|
5
|
+
// middle fingertip (region "super"). The two b/♮ pairs (58/59, 70/71) share a
|
|
6
|
+
// locus by design — one joint, two readings (fa in molle, mi in durum), the
|
|
7
|
+
// tradition, not a duplication to fix. After Suñol [biblio: sunol-textbook] and
|
|
8
|
+
// Apel [biblio: apel-chant]; realized in working/diagram-hand-08.html.
|
|
4
9
|
// prettier-ignore
|
|
5
10
|
export const GUIDONIAN_DATA = new Map([
|
|
6
|
-
[43, { hand: { finger: "
|
|
7
|
-
[45, { hand: { finger: "
|
|
8
|
-
[47, { hand: { finger: "
|
|
9
|
-
[48, { hand: { finger: "
|
|
10
|
-
[50, { hand: { finger: "
|
|
11
|
-
[52, { hand: { finger: "
|
|
12
|
-
[53, { hand: { finger: "
|
|
13
|
-
[55, { hand: { finger: "
|
|
14
|
-
[57, { hand: { finger: "
|
|
15
|
-
[58, { hand: { finger: "
|
|
16
|
-
[59, { hand: { finger: "
|
|
17
|
-
[60, { hand: { finger: "
|
|
18
|
-
[62, { hand: { finger: "middle", region: "
|
|
19
|
-
[64, { hand: { finger: "
|
|
20
|
-
[65, { hand: { finger: "
|
|
21
|
-
[67, { hand: { finger: "
|
|
22
|
-
[69, { hand: { finger: "
|
|
23
|
-
[70, { hand: { finger: "ring", region: "
|
|
24
|
-
[71, { hand: { finger: "
|
|
25
|
-
[72, { hand: { finger: "
|
|
26
|
-
[74, { hand: { finger: "
|
|
27
|
-
[76, { hand: { finger: "
|
|
11
|
+
[43, { hand: { finger: "thumb", region: "tip" }, name: ["Γ", "Gammaut"], variants: [{ hexachord: "durum", solmization: "UT" }] }],
|
|
12
|
+
[45, { hand: { finger: "thumb", region: "mid" }, name: ["A", "Are"], variants: [{ hexachord: "durum", solmization: "RE" }] }],
|
|
13
|
+
[47, { hand: { finger: "thumb", region: "base" }, name: ["B", "Bemi"], variants: [{ hexachord: "durum", solmization: "MI" }] }],
|
|
14
|
+
[48, { hand: { finger: "index", region: "base" }, name: ["C", "Cefaut"], variants: [{ hexachord: "durum", solmization: "FA" }, { hexachord: "naturale", solmization: "UT" }] }],
|
|
15
|
+
[50, { hand: { finger: "middle", region: "base" }, name: ["D", "Desolre"], variants: [{ hexachord: "durum", solmization: "SOL" }, { hexachord: "naturale", solmization: "RE" }] }],
|
|
16
|
+
[52, { hand: { finger: "ring", region: "base" }, name: ["E", "Elami"], variants: [{ hexachord: "durum", solmization: "LA" }, { hexachord: "naturale", solmization: "MI" }] }],
|
|
17
|
+
[53, { hand: { finger: "pinky", region: "base" }, name: ["F", "Fefaut"], variants: [{ hexachord: "naturale", solmization: "FA" }, { hexachord: "molle", solmization: "UT" }] }],
|
|
18
|
+
[55, { hand: { finger: "pinky", region: "mid" }, name: ["G", "Gesolreut"], variants: [{ hexachord: "durum", solmization: "UT" }, { hexachord: "naturale", solmization: "SOL" }, { hexachord: "molle", solmization: "RE" }] }],
|
|
19
|
+
[57, { hand: { finger: "pinky", region: "top" }, name: ["a", "Alamire"], variants: [{ hexachord: "durum", solmization: "RE" }, { hexachord: "naturale", solmization: "LA" }, { hexachord: "molle", solmization: "MI" }] }],
|
|
20
|
+
[58, { hand: { finger: "pinky", region: "tip" }, name: ["b", "Befa"], variants: [{ hexachord: "molle", solmization: "FA" }] }],
|
|
21
|
+
[59, { hand: { finger: "pinky", region: "tip" }, name: ["♮", "Bemi"], variants: [{ hexachord: "durum", solmization: "MI" }] }],
|
|
22
|
+
[60, { hand: { finger: "ring", region: "tip" }, name: ["c", "Cesolfaut"], variants: [{ hexachord: "durum", solmization: "FA" }, { hexachord: "naturale", solmization: "UT" }, { hexachord: "molle", solmization: "SOL" }] }],
|
|
23
|
+
[62, { hand: { finger: "middle", region: "tip" }, name: ["d", "Delasolre"], variants: [{ hexachord: "durum", solmization: "SOL" }, { hexachord: "naturale", solmization: "RE" }, { hexachord: "molle", solmization: "LA" }] }],
|
|
24
|
+
[64, { hand: { finger: "index", region: "tip" }, name: ["e", "Elami"], variants: [{ hexachord: "durum", solmization: "LA" }, { hexachord: "naturale", solmization: "MI" }] }],
|
|
25
|
+
[65, { hand: { finger: "index", region: "top" }, name: ["f", "Fefaut"], variants: [{ hexachord: "naturale", solmization: "FA" }, { hexachord: "molle", solmization: "UT" }] }],
|
|
26
|
+
[67, { hand: { finger: "index", region: "mid" }, name: ["g", "Gesolreut"], variants: [{ hexachord: "durum", solmization: "UT" }, { hexachord: "naturale", solmization: "SOL" }, { hexachord: "molle", solmization: "RE" }] }],
|
|
27
|
+
[69, { hand: { finger: "middle", region: "mid" }, name: ["aa", "Alamire"], variants: [{ hexachord: "durum", solmization: "RE" }, { hexachord: "naturale", solmization: "LA" }, { hexachord: "molle", solmization: "MI" }] }],
|
|
28
|
+
[70, { hand: { finger: "ring", region: "mid" }, name: ["bb", "Befa"], variants: [{ hexachord: "molle", solmization: "FA" }] }],
|
|
29
|
+
[71, { hand: { finger: "ring", region: "mid" }, name: ["♮♮", "Bemi"], variants: [{ hexachord: "durum", solmization: "MI" }] }],
|
|
30
|
+
[72, { hand: { finger: "middle", region: "top" }, name: ["cc", "Cesolfa"], variants: [{ hexachord: "durum", solmization: "FA" }, { hexachord: "naturale", solmization: "UT" }, { hexachord: "molle", solmization: "SOL" }] }],
|
|
31
|
+
[74, { hand: { finger: "ring", region: "top" }, name: ["dd", "Delasol"], variants: [{ hexachord: "durum", solmization: "SOL" }, { hexachord: "naturale", solmization: "RE" }, { hexachord: "molle", solmization: "LA" }] }],
|
|
32
|
+
[76, { hand: { finger: "middle", region: "super" }, name: ["ee", "Ela"], variants: [{ hexachord: "durum", solmization: "LA" }, { hexachord: "naturale", solmization: "MI" }] }],
|
|
28
33
|
]);
|
|
29
34
|
//# sourceMappingURL=guido.js.map
|
|
@@ -39,6 +39,9 @@ export const MODES = new Map([
|
|
|
39
39
|
scalePcs: [2, 4, 5, 7, 9, 11, 0],
|
|
40
40
|
hexachords: ["naturale"],
|
|
41
41
|
profile: { mood: "sad", ethos: "tristis", phrasing: "lyrical", melodic: "arch", tendency: "neumatic" },
|
|
42
|
+
// Shared with mode 1 (both Protus). An earlier seed here was wrong — a copy
|
|
43
|
+
// of mode 5's figures, landing on F with an Ab outside hypodorian's scale;
|
|
44
|
+
// Suñol's tables surfaced it, and it was corrected to the Protus descent.
|
|
42
45
|
cadences: [
|
|
43
46
|
{ id: "mi-re", steps: [1, 0] },
|
|
44
47
|
{ id: "ut-re", steps: [-1, 0] },
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// temper/data/tones — psalm tones (Graduale Romanum appendix)
|
|
2
2
|
// Pitches as MIDI numbers; tone codes follow DO convention: "1g", "6F", "4e".
|
|
3
3
|
// c4 clef reference: f=53 g=55 h=57 i=59 j=60 k=62 l=64 (F3–F4)
|
|
4
4
|
const f = 53, g = 55, h = 57, i = 59, j = 60, k = 62, l = 64;
|