linked-rolls 0.12.0 → 0.13.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/lib/alignment.d.ts +19 -6
- package/lib/alignment.js +112 -44
- package/package.json +1 -1
package/lib/alignment.d.ts
CHANGED
|
@@ -9,14 +9,27 @@ export declare const applyScale: (factor: number, copy: RollCopy) => void;
|
|
|
9
9
|
export declare const revertShift: (copy: RollCopy) => void;
|
|
10
10
|
/** Takes the scale off the copy's features again, as far as one was applied. */
|
|
11
11
|
export declare const revertScale: (copy: RollCopy) => void;
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* How a copy's places are carried onto another copy's:
|
|
14
|
+
* `x_other = (x + shift) · scale`.
|
|
15
|
+
*/
|
|
16
|
+
export interface AlignmentResult {
|
|
13
17
|
/** Applied before the scale. */
|
|
14
18
|
shift: Millimeters;
|
|
15
19
|
scale: number;
|
|
16
|
-
|
|
20
|
+
/** The notes of the copy that found their counterpart on the other. */
|
|
21
|
+
matched: number;
|
|
22
|
+
/** How far the counterparts still lie apart, as a root mean square in the other copy's millimetres. */
|
|
23
|
+
residual: Millimeters;
|
|
24
|
+
}
|
|
17
25
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
26
|
+
* Finds the shift and scale that carry the places of `rollA` onto those
|
|
27
|
+
* of `rollB`, reading both through the bar. Runs of pitches that occur
|
|
28
|
+
* once on each roll anchor a first line; then every note is paired with
|
|
29
|
+
* its nearest counterpart of the same pitch and the line refitted, the
|
|
30
|
+
* window closing each time. A copy cut for another paper speed, a scan
|
|
31
|
+
* with a calibration pattern, and holes the other copy lacks are all
|
|
32
|
+
* within reach of that. Nothing is found where the rolls share no run of
|
|
33
|
+
* pitches, as between two different pieces.
|
|
20
34
|
*/
|
|
21
|
-
export declare function alignFeatures(rollA: AnyFeature[], rollB: AnyFeature[], bar?: TrackerBar): AlignmentResult;
|
|
22
|
-
export {};
|
|
35
|
+
export declare function alignFeatures(rollA: readonly AnyFeature[], rollB: readonly AnyFeature[], bar?: TrackerBar): AlignmentResult | undefined;
|
package/lib/alignment.js
CHANGED
|
@@ -53,57 +53,125 @@ export const revertScale = (copy) => {
|
|
|
53
53
|
copy.ops = copy.ops.filter(op => op !== 'stretched');
|
|
54
54
|
delete copy.measurements.scale;
|
|
55
55
|
};
|
|
56
|
-
const
|
|
57
|
-
|
|
56
|
+
const byPlace = (x, y) => x.at - y.at || x.pitch - y.pitch;
|
|
57
|
+
/** The notes the bar reads off the features, in the order they pass it. */
|
|
58
|
+
const noteOnsets = (features, bar) => features
|
|
59
|
+
.flatMap((feature) => {
|
|
60
|
+
if (feature.type !== 'Hole')
|
|
61
|
+
return [];
|
|
62
|
+
const meaning = bar.meaningOf(feature.vertical.from);
|
|
63
|
+
return meaning?.type === 'note' ? [{ pitch: meaning.pitch, at: feature.horizontal.from }] : [];
|
|
64
|
+
})
|
|
65
|
+
.sort(byPlace);
|
|
66
|
+
const median = (values) => {
|
|
67
|
+
const sorted = [...values].sort((x, y) => x - y);
|
|
68
|
+
return sorted[Math.floor(sorted.length / 2)] ?? 0;
|
|
58
69
|
};
|
|
70
|
+
/** The slope of the line through two matches, or nothing where they share a place on the copy. */
|
|
71
|
+
const slopeBetween = (m, n) => n.a === m.a ? [] : [(n.b - m.b) / (n.a - m.a)];
|
|
72
|
+
/** Matches beyond this are thinned before the slopes are taken, whose number grows with the square. */
|
|
73
|
+
const SLOPE_SAMPLE = 1500;
|
|
59
74
|
/**
|
|
60
|
-
*
|
|
75
|
+
* The line of the median slope and the median intercept, after Theil
|
|
76
|
+
* and Sen. A minority of matches at odds with the rest, as from a
|
|
77
|
+
* passage the copy retimes or from an anchor set by chance, leaves it
|
|
78
|
+
* unmoved, where a least-squares line would tilt towards them.
|
|
61
79
|
*/
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
80
|
+
const robustLine = (matches) => {
|
|
81
|
+
const step = Math.max(1, Math.ceil(matches.length / SLOPE_SAMPLE));
|
|
82
|
+
const sample = matches.filter((_, i) => i % step === 0);
|
|
83
|
+
const slopes = sample.flatMap((m, i) => sample.slice(i + 1).flatMap(n => slopeBetween(m, n)));
|
|
84
|
+
if (slopes.length === 0)
|
|
85
|
+
return undefined;
|
|
86
|
+
const slope = median(slopes);
|
|
87
|
+
return { slope, intercept: median(matches.map(m => m.b - slope * m.a)) };
|
|
88
|
+
};
|
|
89
|
+
const placed = (line, a) => line.slope * a + line.intercept;
|
|
90
|
+
const residualOf = (line, match) => match.b - placed(line, match.a);
|
|
91
|
+
/** Pitches in a row that pin down a place on a roll, provided the row occurs once. */
|
|
92
|
+
const ANCHOR_LENGTH = 6;
|
|
93
|
+
/** Every run of `length` pitches, keyed by the run, with the onsets it starts at. */
|
|
94
|
+
const runsOf = (onsets, length) => {
|
|
95
|
+
const runs = new Map();
|
|
96
|
+
onsets.slice(0, Math.max(0, onsets.length - length + 1)).forEach((start, i) => {
|
|
97
|
+
const key = onsets.slice(i, i + length).map(onset => onset.pitch).join(',');
|
|
98
|
+
runs.set(key, [...(runs.get(key) ?? []), start]);
|
|
99
|
+
});
|
|
100
|
+
return runs;
|
|
101
|
+
};
|
|
102
|
+
/** The places a run of pitches found once on each roll ties together. */
|
|
103
|
+
const anchors = (a, b) => {
|
|
104
|
+
const runsB = runsOf(b, ANCHOR_LENGTH);
|
|
105
|
+
return [...runsOf(a, ANCHOR_LENGTH)]
|
|
106
|
+
.flatMap(([key, starts]) => {
|
|
107
|
+
const others = runsB.get(key);
|
|
108
|
+
return starts.length === 1 && others?.length === 1
|
|
109
|
+
? [{ a: starts[0].at, b: others[0].at }]
|
|
110
|
+
: [];
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
/** Takes the candidates nearest first, each onset going into one pair only. */
|
|
114
|
+
const pairedNearestFirst = (candidates) => {
|
|
115
|
+
const taken = new Set();
|
|
116
|
+
const matches = [];
|
|
117
|
+
for (const candidate of [...candidates].sort((x, y) => x.distance - y.distance)) {
|
|
118
|
+
if (taken.has(candidate.a) || taken.has(candidate.b))
|
|
119
|
+
continue;
|
|
120
|
+
taken.add(candidate.a);
|
|
121
|
+
taken.add(candidate.b);
|
|
122
|
+
matches.push({ a: candidate.a.at, b: candidate.b.at });
|
|
72
123
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
124
|
+
return matches;
|
|
125
|
+
};
|
|
126
|
+
/** Each onset of A paired with the nearest onset of B of its pitch within the window around where the line puts it. */
|
|
127
|
+
const nearestMatches = (a, b, line, window) => {
|
|
128
|
+
const bByPitch = new Map();
|
|
129
|
+
b.forEach(onset => bByPitch.set(onset.pitch, [...(bByPitch.get(onset.pitch) ?? []), onset]));
|
|
130
|
+
const candidates = a.flatMap(onset => {
|
|
131
|
+
const expected = placed(line, onset.at);
|
|
132
|
+
return (bByPitch.get(onset.pitch) ?? [])
|
|
133
|
+
.map(other => ({ distance: Math.abs(other.at - expected), a: onset, b: other }))
|
|
134
|
+
.filter(candidate => candidate.distance <= window);
|
|
135
|
+
});
|
|
136
|
+
return pairedNearestFirst(candidates);
|
|
137
|
+
};
|
|
77
138
|
/**
|
|
78
|
-
*
|
|
139
|
+
* Windows, in the other roll's millimetres, narrowed as the line settles.
|
|
140
|
+
* The first is wide enough to catch what a line extrapolated from the
|
|
141
|
+
* anchors misses at the ends of a long roll; the last is narrow enough
|
|
142
|
+
* to leave out a passage the copy retimed.
|
|
79
143
|
*/
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
144
|
+
const WINDOWS = [mm(40), mm(15), mm(6)];
|
|
145
|
+
const settled = (a, b) => (fit, window) => {
|
|
146
|
+
const matches = nearestMatches(a, b, fit.line, window);
|
|
147
|
+
return { line: robustLine(matches) ?? fit.line, matches };
|
|
148
|
+
};
|
|
149
|
+
const resultOf = ({ line, matches }) => {
|
|
150
|
+
if (matches.length < 2 || line.slope <= 0)
|
|
151
|
+
return undefined;
|
|
152
|
+
const squares = matches.reduce((total, m) => total + residualOf(line, m) ** 2, 0);
|
|
153
|
+
return {
|
|
154
|
+
shift: mm(line.intercept / line.slope),
|
|
155
|
+
scale: line.slope,
|
|
156
|
+
matched: matches.length,
|
|
157
|
+
residual: mm(Math.sqrt(squares / matches.length))
|
|
158
|
+
};
|
|
159
|
+
};
|
|
86
160
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
161
|
+
* Finds the shift and scale that carry the places of `rollA` onto those
|
|
162
|
+
* of `rollB`, reading both through the bar. Runs of pitches that occur
|
|
163
|
+
* once on each roll anchor a first line; then every note is paired with
|
|
164
|
+
* its nearest counterpart of the same pitch and the line refitted, the
|
|
165
|
+
* window closing each time. A copy cut for another paper speed, a scan
|
|
166
|
+
* with a calibration pattern, and holes the other copy lacks are all
|
|
167
|
+
* within reach of that. Nothing is found where the rolls share no run of
|
|
168
|
+
* pitches, as between two different pieces.
|
|
89
169
|
*/
|
|
90
170
|
export function alignFeatures(rollA, rollB, bar = welteT100) {
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
// 3. Select only first and last segments
|
|
98
|
-
const XA = selectEnds(allXA, segCount);
|
|
99
|
-
const idxA = XA.map((_, i) => i);
|
|
100
|
-
const XB = selectEnds(allXB, segCount);
|
|
101
|
-
const idxB = XB.map((_, i) => i);
|
|
102
|
-
// 4. Fit index->position for each roll on selected ends
|
|
103
|
-
const { alpha: alphaA, beta: betaA } = fitIndexToPosition(idxA, XA);
|
|
104
|
-
const { alpha: alphaB, beta: betaB } = fitIndexToPosition(idxB, XB);
|
|
105
|
-
// 5. Derive scale and shift such that x2 = (x1 + shift) * scale
|
|
106
|
-
const scale = alphaB / alphaA;
|
|
107
|
-
const shift = mm(betaB / scale - betaA);
|
|
108
|
-
return { scale, shift };
|
|
171
|
+
const a = noteOnsets(rollA, bar);
|
|
172
|
+
const b = noteOnsets(rollB, bar);
|
|
173
|
+
const coarse = robustLine(anchors(a, b));
|
|
174
|
+
if (!coarse)
|
|
175
|
+
return undefined;
|
|
176
|
+
return resultOf(WINDOWS.reduce(settled(a, b), { line: coarse, matches: [] }));
|
|
109
177
|
}
|
package/package.json
CHANGED