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.
@@ -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
- type AlignmentResult = {
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
- * Align two rolls by computing independent linear fits of each roll's note-onset positions
19
- * using only the first and last segments, then deriving a transform x2 = (x1 + shift) * scale.
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 isNoteOn = (bar) => (feature) => {
57
- return feature.type === 'Hole' && bar.meaningOf(feature.vertical.from)?.type === 'note';
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
- * Fit a line: position = alpha * index + beta via least squares.
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
- function fitIndexToPosition(indices, positions) {
63
- const n = indices.length;
64
- const meanIdx = indices.reduce((s, i) => s + i, 0) / n;
65
- const meanPos = positions.reduce((s, p) => s + p, 0) / n;
66
- let num = 0;
67
- let den = 0;
68
- for (let i = 0; i < n; i++) {
69
- const d = indices[i] - meanIdx;
70
- num += d * (positions[i] - meanPos);
71
- den += d * d;
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
- const alpha = den === 0 ? 1 : num / den;
74
- const beta = meanPos - alpha * meanIdx;
75
- return { alpha, beta };
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
- * Selects the first and last N elements of an array (or fewer if length is smaller).
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
- function selectEnds(arr, count) {
81
- const n = arr.length;
82
- if (count * 2 >= n)
83
- return arr.slice();
84
- return arr.slice(0, count).concat(arr.slice(n - count, n));
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
- * Align two rolls by computing independent linear fits of each roll's note-onset positions
88
- * using only the first and last segments, then deriving a transform x2 = (x1 + shift) * scale.
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
- // 1. Extract note-onset positions
92
- const isNote = isNoteOn(bar);
93
- const allXA = rollA.filter(isNote).map(f => f.horizontal.from);
94
- const allXB = rollB.filter(isNote).map(f => f.horizontal.from);
95
- // 2. Determine segment size (e.g. 10% of notes, min 5)
96
- const segCount = Math.max(5, Math.floor(allXA.length * 0.1));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linked-rolls",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Digital editions of piano rolls: import, collation, editorial assumptions, JSON-LD export, and emulation through a reproducing system",
5
5
  "license": "MIT",
6
6
  "repository": {