tonus 0.1.4 → 0.1.8
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 +74 -0
- package/README.md +7 -9
- package/dist/data/am.d.ts +5 -0
- package/dist/data/am.js +14302 -0
- package/dist/data/corpus-overlap.d.ts +7 -0
- package/dist/data/corpus-overlap.js +53 -0
- package/dist/data/gr.js +3 -0
- package/dist/data/la.js +3 -0
- package/dist/data/lh.js +3 -0
- package/dist/data/lu.js +4 -1
- package/dist/data/nocturnale-romanum.d.ts +5 -0
- package/dist/data/nocturnale-romanum.js +12524 -0
- package/dist/data/office-matins-roman.d.ts +19 -0
- package/dist/data/office-matins-roman.js +4383 -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.d.ts +7 -1
- package/dist/engines/chant/chant.js +72 -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/matutinum.d.ts +33 -0
- package/dist/engines/chant/matutinum.js +81 -0
- 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 +37 -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 +8 -4
- package/dist/index.js +4 -1
- 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
|
@@ -20,6 +20,14 @@ function resolveScale(temper) {
|
|
|
20
20
|
}
|
|
21
21
|
return buildRatios();
|
|
22
22
|
}
|
|
23
|
+
// Reduce a time range's per-frame voicings to one aggregate body list for the
|
|
24
|
+
// top-level `bodies`. Note the deliberate asymmetry: the DYNAMICS (presence,
|
|
25
|
+
// motion) are mean-averaged across the range, but the REPRESENTATIVE identity —
|
|
26
|
+
// pitch, vowel, zodiac, retrograde — is taken from the first frame, not averaged
|
|
27
|
+
// (a pitch has no meaningful mean, and averaging a wrapping zodiac longitude is
|
|
28
|
+
// nonsense). So an aggregate body sounds the range's *starting* pitch with its
|
|
29
|
+
// *mean* loudness. Callers wanting the pitch to track the range should read the
|
|
30
|
+
// per-frame `frames` instead. (Aspects are likewise taken from frame 0; see below.)
|
|
23
31
|
function averageBodies(frames) {
|
|
24
32
|
if (frames.length === 0)
|
|
25
33
|
return [];
|
|
@@ -71,6 +79,9 @@ export function buildHarmonia(input, opts = {}) {
|
|
|
71
79
|
frames.push({ date: cosmos.date, bodies: vb, aspects: va });
|
|
72
80
|
}
|
|
73
81
|
const aggregateBodies = averageBodies(perCosmosBodies);
|
|
82
|
+
// Aspects are the first frame's, not merged across the range — an aspect forms
|
|
83
|
+
// and dissolves over time, so there is no meaningful "average" set. Like the
|
|
84
|
+
// representative pitch above, this reflects the range's start.
|
|
74
85
|
const aggregateAspects = frames[0]?.aspects ?? [];
|
|
75
86
|
const imprint = computeImprintFromBodies(aggregateBodies, scale);
|
|
76
87
|
const harmony = {
|
|
@@ -10,12 +10,38 @@
|
|
|
10
10
|
// center, not a voiced body (except Pliny, where Earth is a boundary tone).
|
|
11
11
|
// The Sun holds the mese position in all four systems.
|
|
12
12
|
//
|
|
13
|
-
//
|
|
13
|
+
// The ratios are reconstructed from the primary texts [biblio:
|
|
14
|
+
// doctrina-primaries] through Godwin's syntheses [biblio: godwin-harmonies,
|
|
15
|
+
// godwin-spheres]: for each author the Greek tonal framework is identified
|
|
16
|
+
// (conjunct/disjunct tetrachords, or the fixed Greater Perfect System),
|
|
17
|
+
// bodies are mapped to Greek tone-names from the source's explicit
|
|
18
|
+
// assignments, and ratios are derived by Pythagorean interval arithmetic
|
|
19
|
+
// (tone 9/8, limma 256/243, fourth 4/3, fifth 3/2) normalized to the mese
|
|
20
|
+
// (Sun = 1/1), then verified computationally for span and closure.
|
|
14
21
|
//
|
|
15
22
|
// Taxonomy follows Godwin's classification:
|
|
16
23
|
// Type A — intervals represent distances between spheres
|
|
17
24
|
// Type B — intervals represent speeds/symbolic scale degrees
|
|
18
25
|
// Type C — intervals map to fixed tones of the Greater Perfect System
|
|
26
|
+
//
|
|
27
|
+
// Decisions recorded from the derivation:
|
|
28
|
+
// 1. Boethius transmits Nicomachus's ratios; tonus credits the doctrina to
|
|
29
|
+
// Boethius as the medieval authority.
|
|
30
|
+
// 2. Pliny's Sun sits at hypate meson, not the Greek mese; tonus keeps the
|
|
31
|
+
// Sun as the structural center regardless, and greekName reflects
|
|
32
|
+
// Pliny's actual position.
|
|
33
|
+
// 3. Ptolemy's aspect–consonance mapping carries into interval grading — a
|
|
34
|
+
// Jupiter–Sun aspect sounds a fifth, Mars–Sun a tone.
|
|
35
|
+
// 4. Pythagoras's Fixed Stars complete the octave in the data but are never
|
|
36
|
+
// voiced: no caelum body, no classical vowel.
|
|
37
|
+
// 5. Voices are stored in sphere order, outermost first; sort by ratio for
|
|
38
|
+
// scale views.
|
|
39
|
+
//
|
|
40
|
+
// The single pitch that separates Pythagoras from Boethius is Venus — the
|
|
41
|
+
// diagnostic of the whole tradition: a whole tone above the Sun in the
|
|
42
|
+
// disjunct system (9/8, B durum), a semitone in the conjunct (256/243, B
|
|
43
|
+
// molle). This is the same durum/molle distinction the GABC parser tracks as
|
|
44
|
+
// the `bmolle` accidental. See the per-doctrina notes below.
|
|
19
45
|
// ---------------------------------------------------------------------------
|
|
20
46
|
// ── Pythagoras ──
|
|
21
47
|
//
|
package/dist/engines/imprint.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { toPitch } from "./temper/pitch.js";
|
|
2
2
|
import { computeModalAffinity } from "./temper/modality.js";
|
|
3
3
|
const DEFAULT_TOP = 5;
|
|
4
|
+
// Attractors are pitch *classes*; they're rendered at a fixed octave (4) purely
|
|
5
|
+
// so they carry a concrete Pitch for display. The octave is not meaningful — do
|
|
6
|
+
// not read it as register.
|
|
4
7
|
const DEFAULT_MIDI_OCTAVE = 4;
|
|
5
8
|
const VOWELS = ["a", "e", "i", "o", "u"];
|
|
6
9
|
function pitchForPc(pc, scale) {
|
|
@@ -70,7 +73,10 @@ function computeVowelAttractors(phrases, scale) {
|
|
|
70
73
|
// A note's contribution to the pc-distribution is raised where it carries more
|
|
71
74
|
// structural weight: on an ictus (the rhythmic footfall) and, above all, when it
|
|
72
75
|
// is a cadence's resolution. Cadence notes are passed in — the imprint sits below
|
|
73
|
-
// the score engine, so it cannot detect them itself.
|
|
76
|
+
// the score engine, so it cannot detect them itself. The principle that ictus and
|
|
77
|
+
// cadence notes are the modally load-bearing ones is Solesmes doctrine [biblio:
|
|
78
|
+
// mocquereau-nombre]; the magnitudes (1.5×, 2×) are a tuned editorial weighting,
|
|
79
|
+
// not a figure from any source — cadence weighted above ictus by intent.
|
|
74
80
|
const ICTUS_WEIGHT = 1.5;
|
|
75
81
|
const CADENCE_WEIGHT = 2;
|
|
76
82
|
/** Build an Imprint from chant phrases, weighting structural notes more. */
|
|
@@ -1,12 +1,33 @@
|
|
|
1
1
|
// ---------------------------------------------------------------------------
|
|
2
2
|
// engines/planet/position — Julian date, astro state, helio/geo position engine
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
|
+
// Two source models sit side by side, and which one runs depends on the body:
|
|
5
|
+
//
|
|
6
|
+
// • Planets — the JPL/Standish Keplerian element tables [biblio: standish-jpl]
|
|
7
|
+
// in orbital.ts, solved with the iterative Newton kepler() solver. See
|
|
8
|
+
// planetPos.
|
|
9
|
+
// • Sun and Moon — Paul Schlyter's tutorial formulae [biblio:
|
|
10
|
+
// schlyter-positions], a lighter model with its own constants, a one-step
|
|
11
|
+
// eccentric-anomaly approximation (the Sun), and a named perturbation series
|
|
12
|
+
// (the Moon). See sunPos / moonPos.
|
|
13
|
+
//
|
|
14
|
+
// The split is deliberate: the JPL tables don't include the Moon, and Schlyter's
|
|
15
|
+
// Sun is accurate enough at the Sun's tiny eccentricity to skip Newton iteration.
|
|
16
|
+
// So the reader should not expect the same constants or solver across bodies.
|
|
17
|
+
//
|
|
18
|
+
// A few time-scale models here are standard astronomy but not yet catalogued in
|
|
19
|
+
// BIBLIOGRAPHY.md (marked "source TBD" at each): the ΔT (TT−UT) polynomial and
|
|
20
|
+
// the mean-obliquity expansion.
|
|
4
21
|
import { sinDeg, cosDeg, atan2Deg, kepler, wrapAngle, toAu, toCartesian, toSpherical, toEquatorial } from "./math.js";
|
|
5
22
|
import { ORBITAL_ELEMENTS } from "./orbital.js";
|
|
6
23
|
const MS_PER_DAY = 86400000;
|
|
7
24
|
// Precomputed Earth radius in AU (used for Moon distance conversion)
|
|
8
25
|
const EARTH_ELEM = ORBITAL_ELEMENTS.get("Earth");
|
|
9
26
|
export const EARTH_RADIUS_AU = toAu(EARTH_ELEM.radius);
|
|
27
|
+
// Mean obliquity of the ecliptic (the tilt used to rotate ecliptic → equatorial
|
|
28
|
+
// coordinates), in degrees. eps0 is the J2000 value 23°26′21.406″; the arcsecond
|
|
29
|
+
// series is the standard IAU 2006 expansion in Julian centuries T from J2000.
|
|
30
|
+
// (Standard astronomy; source not yet catalogued — TBD.)
|
|
10
31
|
function meanObliquity(T) {
|
|
11
32
|
const eps0 = 23 + 26 / 60 + 21.406 / 3600;
|
|
12
33
|
const sec = -46.836769 * T -
|
|
@@ -17,8 +38,14 @@ function meanObliquity(T) {
|
|
|
17
38
|
return eps0 + sec / 3600;
|
|
18
39
|
}
|
|
19
40
|
export function getState(ts) {
|
|
41
|
+
// 2440587.5 is the Julian Date of the Unix epoch; 2451545 is J2000.0 and 36525
|
|
42
|
+
// days is a Julian century — so _T is centuries from J2000 in UT.
|
|
20
43
|
const JD = 2440587.5 + ts / MS_PER_DAY;
|
|
21
44
|
const _T = (JD - 2451545) / 36525;
|
|
45
|
+
// ΔT (TT − UT), in seconds: the drift between civil UT and the uniform
|
|
46
|
+
// Terrestrial Time the ephemerides are reckoned in. This is a coarse local fit
|
|
47
|
+
// valid near the present, not a long-baseline model — a limitation for deep
|
|
48
|
+
// medieval dates. (Standard-form polynomial; source not yet catalogued — TBD.)
|
|
22
49
|
const dT = 64.7 + 64.7 * _T - 0.6 * _T * _T; // seconds
|
|
23
50
|
const TT = JD + dT / 86400;
|
|
24
51
|
const J = TT - 2451545.0;
|
|
@@ -26,12 +53,16 @@ export function getState(ts) {
|
|
|
26
53
|
const eps = meanObliquity(T);
|
|
27
54
|
return { JD, TT, TS: ts, J, T, eps };
|
|
28
55
|
}
|
|
29
|
-
// ── Sun position ──
|
|
56
|
+
// ── Sun position ── (Schlyter's solar elements [biblio: schlyter-positions])
|
|
30
57
|
export function sunPos(state) {
|
|
31
58
|
const { J, eps } = state;
|
|
59
|
+
// omega = longitude of perihelion, e = eccentricity, M = mean anomaly, all as
|
|
60
|
+
// Schlyter's linear-in-J elements (J = days from J2000).
|
|
32
61
|
const omega = 282.9404 + 4.70935e-5 * J;
|
|
33
62
|
const e = 0.016709 - 1.151e-9 * J;
|
|
34
63
|
const M = 356.047 + 0.9856002585 * J;
|
|
64
|
+
// Eccentric anomaly by a single first-order step, not Newton iteration: the
|
|
65
|
+
// Sun's eccentricity is small enough (~0.017) that one correction suffices.
|
|
35
66
|
const E = M + (180 / Math.PI) * e * sinDeg(M) * (1 + e * cosDeg(M));
|
|
36
67
|
const xp = cosDeg(E) - e;
|
|
37
68
|
const yp = Math.sqrt(1 - e * e) * sinDeg(E);
|
|
@@ -54,9 +85,13 @@ export function sunPos(state) {
|
|
|
54
85
|
},
|
|
55
86
|
};
|
|
56
87
|
}
|
|
57
|
-
// ── Moon position ──
|
|
88
|
+
// ── Moon position ── (Schlyter's lunar elements [biblio: schlyter-positions])
|
|
58
89
|
export function moonPos(state, sun) {
|
|
59
90
|
const { J, eps } = state;
|
|
91
|
+
// Orbital elements (J = days from J2000): Omega = ascending node, I =
|
|
92
|
+
// inclination, omega = argument of perigee, a = semi-major axis IN EARTH RADII
|
|
93
|
+
// (not AU — the Moon is measured from Earth; the AU conversion happens at the
|
|
94
|
+
// end via EARTH_RADIUS_AU), e = eccentricity, M = mean anomaly.
|
|
60
95
|
const Omega = wrapAngle(125.1228 - 0.0529538083 * J);
|
|
61
96
|
const I = 5.1454;
|
|
62
97
|
const omega = wrapAngle(318.0634 + 0.1643573223 * J);
|
|
@@ -73,12 +108,18 @@ export function moonPos(state, sun) {
|
|
|
73
108
|
const y = (cw * sO + sw * cO * cI) * xh + (-sw * sO + cw * cO * cI) * yh;
|
|
74
109
|
const z = sw * sI * xh + cw * sI * yh;
|
|
75
110
|
const [lonE, latE, distE] = toSpherical(x, y, z);
|
|
111
|
+
// The perturbation arguments: Lm = Moon's mean longitude, Ms/Ls = Sun's mean
|
|
112
|
+
// anomaly/longitude, D = mean elongation (Moon − Sun), F = argument of latitude
|
|
113
|
+
// (Moon − node). The Sun's disturbing pull is expressed as sines of these.
|
|
76
114
|
const Lm = wrapAngle(Omega + omega + M);
|
|
77
115
|
const Ms = sun.orbit.M;
|
|
78
116
|
const Ls = sun.orbit.L;
|
|
79
117
|
const D = wrapAngle(Lm - Ls);
|
|
80
118
|
const F = wrapAngle(Lm - Omega);
|
|
81
|
-
// Lunar perturbations
|
|
119
|
+
// Lunar perturbations, in degrees — Schlyter's truncated series of the classical
|
|
120
|
+
// named terms. The three largest in longitude are the evection (−1.274·sin(M−2D),
|
|
121
|
+
// the Sun stretching the Moon's ellipse), the variation (+0.658·sin(2D)), and
|
|
122
|
+
// the annual equation (−0.186·sin Ms); the remainder are smaller corrections.
|
|
82
123
|
const lonPerturb = -1.274 * sinDeg(M - 2 * D) + 0.658 * sinDeg(2 * D) - 0.186 * sinDeg(Ms) -
|
|
83
124
|
0.059 * sinDeg(2 * M - 2 * D) - 0.057 * sinDeg(M - 2 * D + Ms) +
|
|
84
125
|
0.053 * sinDeg(M + 2 * D) + 0.046 * sinDeg(2 * D - Ms) +
|
|
@@ -116,12 +157,17 @@ export function planetPos(name, state, sun) {
|
|
|
116
157
|
throw new Error(`Unknown body: ${name}`);
|
|
117
158
|
const { J, T, eps } = state;
|
|
118
159
|
const oe = body.datasets;
|
|
119
|
-
//
|
|
160
|
+
// Two Standish element sets per body (see orbital.ts): [1] is fitted tightly
|
|
161
|
+
// for 1800–2050, [0] trades accuracy for 3000 BC–3000 AD coverage. The bounds
|
|
162
|
+
// are J (days from J2000): −73048.5 ≈ 1800, 18626.5 ≈ 2050. Inside the window
|
|
163
|
+
// use the precise set, outside fall back to the long-range one.
|
|
120
164
|
const dataset = J > -73048.5 && J < 18626.5 ? oe[1] : oe[0];
|
|
121
165
|
const [a, e, I, L, wBar, Omega] = dataset.map(([x0, x1]) => x0 + x1 * T);
|
|
122
166
|
const omega = wBar - Omega; // argument of periapsis
|
|
123
167
|
let M = L - wBar; // mean anomaly
|
|
124
|
-
//
|
|
168
|
+
// Standish's great-inequality correction for the outer planets (Jupiter–Neptune):
|
|
169
|
+
// a secular b·T² plus a long-period cos/sin term at frequency f. Only bodies
|
|
170
|
+
// that carry a datasets[2] (see orbital.ts) get it. [biblio: standish-jpl]
|
|
125
171
|
if (oe[2]) {
|
|
126
172
|
const [b, c, s, f] = oe[2];
|
|
127
173
|
M += b * T * T + c * cosDeg(f * T) + s * sinDeg(f * T);
|
|
@@ -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 =
|