linked-rolls 0.5.0 → 0.5.1

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.
Files changed (41) hide show
  1. package/lib/Agent.d.ts +59 -0
  2. package/lib/Agent.js +1 -0
  3. package/lib/Assumption.d.ts +19 -1
  4. package/lib/Collation.d.ts +19 -0
  5. package/lib/Collation.js +25 -1
  6. package/lib/Edit.d.ts +1 -8
  7. package/lib/Edition.d.ts +4 -77
  8. package/lib/Edition.js +1 -1
  9. package/lib/EditionView.d.ts +0 -7
  10. package/lib/EditionView.js +0 -50
  11. package/lib/ReproducingSystem.d.ts +23 -1
  12. package/lib/RollCopy.d.ts +2 -68
  13. package/lib/RollCopy.js +2 -303
  14. package/lib/TrackerBar.d.ts +16 -14
  15. package/lib/TrackerBar.js +1 -56
  16. package/lib/alignment.d.ts +21 -0
  17. package/lib/alignment.js +117 -0
  18. package/lib/constraints.d.ts +27 -0
  19. package/lib/constraints.js +61 -0
  20. package/lib/editionOps.js +7 -10
  21. package/lib/index.d.ts +18 -13
  22. package/lib/index.js +18 -13
  23. package/lib/migrate.js +2 -1
  24. package/lib/readers/spencerMidi.d.ts +26 -0
  25. package/lib/readers/spencerMidi.js +58 -0
  26. package/lib/readers/stanfordAton.d.ts +18 -0
  27. package/lib/readers/stanfordAton.js +158 -0
  28. package/lib/systems/welteT100/bar.d.ts +14 -0
  29. package/lib/systems/welteT100/bar.js +56 -0
  30. package/lib/systems/{welteT100.d.ts → welteT100/system.d.ts} +1 -1
  31. package/lib/systems/{welteT100.js → welteT100/system.js} +1 -1
  32. package/lib/utils.d.ts +0 -8
  33. package/lib/validate.d.ts +0 -27
  34. package/lib/validate.js +0 -61
  35. package/package.json +3 -3
  36. package/lib/alignFeatures.d.ts +0 -12
  37. package/lib/alignFeatures.js +0 -55
  38. /package/lib/{aton → readers}/AtonParser.d.ts +0 -0
  39. /package/lib/{aton → readers}/AtonParser.js +0 -0
  40. /package/lib/{asMIDISpans.d.ts → readers/midiSpans.d.ts} +0 -0
  41. /package/lib/{asMIDISpans.js → readers/midiSpans.js} +0 -0
package/lib/RollCopy.js CHANGED
@@ -1,77 +1,10 @@
1
- import { AtonParser } from "./aton/AtonParser";
2
1
  import { v4 } from "uuid";
3
- import { read } from "midifile-ts";
4
- import { asSpans } from "./asMIDISpans";
5
- import { welteT100 } from "./TrackerBar";
6
- import { assignReference, idsOf } from "./Assumption";
2
+ import { welteT100 } from "./systems/welteT100/bar";
3
+ import { assignReference } from "./Assumption";
7
4
  export const rollConditions = [
8
5
  'general',
9
6
  'paper-stretch'
10
7
  ];
11
- export const applyShift = (shift, copy) => {
12
- if (copy.ops.includes('shifted'))
13
- return;
14
- const to = copy.features;
15
- for (const event of to) {
16
- event.horizontal.from += shift.horizontal;
17
- if (event.horizontal.to) {
18
- event.horizontal.to += shift.horizontal;
19
- }
20
- event.vertical.from += shift.vertical;
21
- if (event.vertical.to) {
22
- event.vertical.to += shift.vertical;
23
- }
24
- }
25
- copy.ops = [...copy.ops, 'shifted'];
26
- copy.measurements.shift = shift;
27
- };
28
- export const applyStretch = (paperStretch, copy) => {
29
- if (copy.ops.includes('stretched'))
30
- return;
31
- const stretch = paperStretch.factor;
32
- const to = copy.features;
33
- for (const event of to) {
34
- event.horizontal.from *= stretch;
35
- if (event.horizontal.to) {
36
- event.horizontal.to *= stretch;
37
- }
38
- }
39
- copy.ops = [...copy.ops, 'stretched'];
40
- copy.conditions.push(paperStretch);
41
- };
42
- /** Takes the shift off the copy's features again, as far as one was applied. */
43
- export const revertShift = (copy) => {
44
- const shift = copy.measurements.shift;
45
- if (!copy.ops.includes('shifted') || !shift)
46
- return;
47
- copy.features.forEach(feature => {
48
- feature.horizontal.from -= shift.horizontal;
49
- if (feature.horizontal.to) {
50
- feature.horizontal.to -= shift.horizontal;
51
- }
52
- feature.vertical.from -= shift.vertical;
53
- if (feature.vertical.to) {
54
- feature.vertical.to -= shift.vertical;
55
- }
56
- });
57
- copy.ops = copy.ops.filter(op => op !== 'shifted');
58
- delete copy.measurements.shift;
59
- };
60
- const isPaperStretch = (condition) => condition.conditionType === 'paper-stretch';
61
- /** Takes the stretch off the copy's features again, as far as one was applied. */
62
- export const revertStretch = (copy) => {
63
- const stretch = copy.conditions.find(isPaperStretch);
64
- if (!copy.ops.includes('stretched') || !stretch)
65
- return;
66
- copy.features.forEach(feature => {
67
- feature.horizontal.from /= stretch.factor;
68
- if (feature.horizontal.to) {
69
- feature.horizontal.to /= stretch.factor;
70
- }
71
- });
72
- copy.ops = copy.ops.filter(op => op !== 'stretched');
73
- copy.conditions = copy.conditions.filter(condition => !isPaperStretch(condition));
74
- };
75
8
  /**
76
9
  * Reads the features of a copy as the tracker bar would read them.
77
10
  * Holes on a position the bar does not read carry no symbol and are
@@ -131,237 +64,3 @@ export const calibrationOf = (copy) => {
131
64
  shift: 0
132
65
  };
133
66
  };
134
- /** Values in these files carry their unit as a suffix, e.g. "37.7646px". */
135
- const px = (value) => parseFloat(value);
136
- /** The parser writes one record as an object and several as an array. */
137
- const listOf = (value) => value === undefined ? [] : Array.isArray(value) ? value : [value];
138
- /**
139
- * Holes the parser set aside as suspicious after it had already chained
140
- * them into a note: the head of such a chain is usually a punch that
141
- * came out a little short, and leaving it out would lose the whole
142
- * chain. They carry no track number of their own, so the column says
143
- * which track they sit on.
144
- */
145
- const chainedBadHoles = (holes, calibration) => holes
146
- .filter(hole => hole.NOTE_ATTACK && hole.OFF_TIME)
147
- .map(hole => ({
148
- ...hole,
149
- TRACKER_HOLE: `${Math.round((px(hole.CENTROID_COL) - calibration.offset) / calibration.separation)}`
150
- }));
151
- const millimeters = (pixels, dpi) => pixels / dpi * 25.4;
152
- const median = (values) => {
153
- const sorted = [...values].sort((a, b) => a - b);
154
- const middle = Math.floor(sorted.length / 2);
155
- return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
156
- };
157
- const mostFrequent = (values) => {
158
- const counts = values.reduce((acc, value) => acc.set(value, (acc.get(value) || 0) + 1), new Map());
159
- return [...counts].sort(([, a], [, b]) => b - a)[0]?.[0];
160
- };
161
- /**
162
- * Only the first hole of a chain carries an attack and an off time;
163
- * the rest continue it. The rewind perforation carries neither, and it
164
- * is the last thing punched on the roll, so whatever the holes past the
165
- * final musical attack sit on is the rewind track.
166
- */
167
- const rewindTrackIn = (holes) => {
168
- const lastMusical = holes.findLastIndex(hole => hole.NOTE_ATTACK);
169
- const trailing = holes.slice(lastMusical + 1).map(hole => +hole.TRACKER_HOLE);
170
- return mostFrequent(trailing);
171
- };
172
- /**
173
- * The phase of the tracker grid within the image. The analysis file
174
- * usually states it; where it does not, the holes themselves give it away,
175
- * since each sits close to the centre of its column.
176
- */
177
- const gridOffsetOf = (holes, separation, stated) => {
178
- if (stated !== undefined)
179
- return px(stated);
180
- return median(holes.map(hole => px(hole.CENTROID_COL) - +hole.TRACKER_HOLE * separation));
181
- };
182
- const punchDiameterOf = (holes, dpi) => {
183
- const circular = holes
184
- .filter(hole => px(hole.CIRCULARITY) > 0.95)
185
- .map(hole => millimeters(px(hole.PERIMETER), dpi) / Math.PI);
186
- if (!circular.length)
187
- return undefined;
188
- return circular.reduce((sum, diameter) => sum + diameter, 0) / circular.length;
189
- };
190
- /**
191
- * The image service Stanford keeps for a scan, and a crop of a
192
- * feature from it.
193
- */
194
- const stanfordScan = (druid) => ({
195
- scan: `https://stacks.stanford.edu/image/iiif/${druid}%2F${druid}_0001/`,
196
- depictionOf: (column, row, width, height) => `https://stacks.stanford.edu/image/iiif/${druid}/${druid}_0001/${column},${row},${width},${height}/128,/270/default.jpg`
197
- });
198
- /**
199
- * The software behind an analysis and when it was run, as the
200
- * analysis file states them.
201
- */
202
- const measuredByOf = (rollinfo) => {
203
- const date = new Date(rollinfo.ANALYSIS_DATE);
204
- if (!rollinfo.HOLE_SOFTWARE || isNaN(date.getTime()))
205
- return undefined;
206
- return {
207
- software: rollinfo.HOLE_SOFTWARE,
208
- version: rollinfo.SOFTWARE_DATE ?? '',
209
- date
210
- };
211
- };
212
- export function readFromStanfordAton(atonString, { trackShift, bar = welteT100, scan } = {}) {
213
- const parser = new AtonParser();
214
- const json = parser.parse(atonString);
215
- const holes = json.ROLLINFO.HOLES.HOLE;
216
- const druid = json.ROLLINFO.DRUID;
217
- const stanford = druid ? stanfordScan(druid) : undefined;
218
- const separation = px(json.ROLLINFO.HOLE_SEPARATION);
219
- const dpi = parseFloat(json.ROLLINFO.LENGTH_DPI);
220
- const measuredBy = measuredByOf(json.ROLLINFO);
221
- const rewindTrack = rewindTrackIn(holes);
222
- const shift = trackShift
223
- ?? (rewindTrack === undefined ? 0 : bar.rewindTrack - rewindTrack);
224
- const calibration = {
225
- unit: 'px',
226
- offset: gridOffsetOf(holes, separation, json.ROLLINFO.HOLE_OFFSET),
227
- separation,
228
- shift
229
- };
230
- const punchDiameter = punchDiameterOf(holes, dpi);
231
- const chains = [...holes, ...chainedBadHoles(listOf(json.ROLLINFO.BADHOLES?.HOLE), calibration)]
232
- .filter(hole => hole.NOTE_ATTACK && hole.OFF_TIME)
233
- .sort((a, b) => px(a.NOTE_ATTACK) - px(b.NOTE_ATTACK));
234
- const features = chains
235
- .map((hole) => {
236
- const attack = px(hole.NOTE_ATTACK);
237
- const release = px(hole.OFF_TIME);
238
- const column = px(hole.ORIGIN_COL);
239
- const columnWidth = px(hole.WIDTH_COL);
240
- return {
241
- type: 'Hole',
242
- id: v4(),
243
- ...(stanford && {
244
- depiction: stanford.depictionOf(column, attack, columnWidth, release - attack)
245
- }),
246
- vertical: {
247
- from: +hole.TRACKER_HOLE + shift,
248
- unit: 'track'
249
- },
250
- horizontal: {
251
- unit: 'mm',
252
- from: millimeters(attack, dpi),
253
- to: millimeters(release, dpi)
254
- }
255
- };
256
- });
257
- return {
258
- type: 'RollCopy',
259
- id: v4(),
260
- ops: [],
261
- conditions: [],
262
- keeper: { name: '', sameAs: [] },
263
- modifications: [],
264
- ...((scan ?? stanford) && { scan: scan ?? stanford?.scan }),
265
- measurements: {
266
- dimensions: {
267
- width: millimeters(px(json.ROLLINFO.ROLL_WIDTH), dpi),
268
- height: millimeters(px(json.ROLLINFO.IMAGE_LENGTH), dpi),
269
- unit: 'mm'
270
- },
271
- ...(punchDiameter !== undefined && {
272
- punchDiameter: { value: punchDiameter, unit: 'mm' }
273
- }),
274
- holeSeparation: {
275
- value: separation,
276
- unit: 'px'
277
- },
278
- margins: {
279
- treble: px(json.ROLLINFO.HARD_MARGIN_TREBLE),
280
- bass: px(json.ROLLINFO.HARD_MARGIN_BASS),
281
- unit: 'px'
282
- },
283
- trackCalibration: calibration,
284
- ...(measuredBy && { measuredBy })
285
- },
286
- features
287
- };
288
- }
289
- /**
290
- * How a MIDI key number in one of Spencer Chase's roll files names a
291
- * tracker bar track.
292
- *
293
- * The note block follows the obvious rule, `pitch - 13`, which puts
294
- * track 11 on MIDI 24 as the T100 compass requires. The bass expression
295
- * block does not: it reads two tracks high, and subtracting two is what
296
- * has made these files come out right so far.
297
- *
298
- * The boundary between the two rules is unresolved. Taken literally the
299
- * rules leave tracks 8 and 9 unreachable and jump from track 7 to track 10,
300
- * which no lateral offset can produce, so at least one of them is
301
- * approximate. Settling it needs a Spencer file whose expression holes
302
- * can be checked against the roll, hence the option to override.
303
- */
304
- export const spencerTrackOf = (pitch) => {
305
- const track = pitch - 13;
306
- return track < 10 ? track - 2 : track;
307
- };
308
- const MM_PER_FOOT = 304.8;
309
- /**
310
- * Spencer Chase's rolls seem to be scanned at a roll speed of
311
- * 83 (=8.3 feet per minute). A scanner feeds the paper at one
312
- * speed, so time in his files is proportional to place.
313
- */
314
- export const SPENCER_FEET_PER_MINUTE = 8.3;
315
- /** Place on the roll in mm after `seconds` at a constant `feetPerMinute`. */
316
- export const atConstantSpeed = (feetPerMinute) => (seconds) => feetPerMinute * MM_PER_FOOT / 60 * seconds;
317
- export function readFromSpencerMIDI(midiBuffer, placeAt = atConstantSpeed(SPENCER_FEET_PER_MINUTE), trackOf = spencerTrackOf) {
318
- const features = asSpans(read(midiBuffer))
319
- .filter(span => span.type === 'note')
320
- .map((span) => ({
321
- type: 'Hole',
322
- id: v4(),
323
- vertical: {
324
- from: trackOf(span.pitch),
325
- unit: 'track'
326
- },
327
- horizontal: {
328
- from: placeAt(span.onsetMs / 1000),
329
- to: placeAt(span.offsetMs / 1000),
330
- unit: 'mm'
331
- }
332
- }));
333
- return {
334
- type: 'RollCopy',
335
- id: v4(),
336
- ops: [],
337
- conditions: [],
338
- keeper: { name: '', sameAs: [] },
339
- measurements: {},
340
- modifications: [],
341
- features
342
- };
343
- }
344
- /**
345
- * Moves features across the tracker bar. What the new position means is
346
- * left to the tracker bar to say, since meaning belongs to the symbols
347
- * read off a copy rather than to the holes themselves.
348
- */
349
- export function shiftVertically(features, amount) {
350
- features.forEach(feature => {
351
- feature.vertical.from += amount;
352
- if (feature.vertical.to !== undefined) {
353
- feature.vertical.to += amount;
354
- }
355
- });
356
- }
357
- export const findCopiesCarrying = (sources, symbol) => {
358
- const result = new Set();
359
- for (const feature of idsOf(symbol.carriers)) {
360
- for (const copy of sources) {
361
- if (copy.features.findIndex(f => f.id === feature)) {
362
- result.add(copy.id);
363
- }
364
- }
365
- }
366
- return result;
367
- };
@@ -1,4 +1,4 @@
1
- import type { Concept } from "./Edition";
1
+ import type { Concept } from "./Agent";
2
2
  import { Expression, Note } from "./Symbol";
3
3
  /**
4
4
  * What a tracker bar position does: sound a note, or operate one of
@@ -58,16 +58,18 @@ export interface TrackerBar {
58
58
  export declare const systemOf: (bar: TrackerBar) => Concept;
59
59
  /** The identifier of a system the type vocabulary knows, from its concept. */
60
60
  export declare const systemIdOf: (system: Concept | undefined) => string | undefined;
61
- /**
62
- * The commands of the Welte-Mignon T-100, as its tracker bar reads them.
63
- */
64
- export declare const welteT100ExpressionTypes: readonly ["SustainPedalOn", "SustainPedalOff", "SoftPedalOn", "SoftPedalOff", "MezzoforteOff", "MezzoforteOn", "SlowCrescendoOn", "SlowCrescendoOff", "ForzandoOn", "ForzandoOff", "MotorOff", "MotorOn", "Rewind", "ElectricCutOff"];
65
- export type WelteT100ExpressionType = typeof welteT100ExpressionTypes[number];
66
- /**
67
- * Welte-Mignon T-100 ("red Welte"), cf. Hagmann, pp. 75 and 178.
68
- *
69
- * The note block spans 80 positions from C1 to g⁴, i.e. MIDI 24 to 103.
70
- * The expression valves are duplicated, bass below the note block and
71
- * treble above it, in mirrored order.
72
- */
73
- export declare const welteT100: TrackerBar;
61
+ export interface TrackerBarSpec {
62
+ id: string;
63
+ name: string;
64
+ width: number;
65
+ trackCount: number;
66
+ /** The contiguous block of note positions. */
67
+ notes: {
68
+ from: number;
69
+ to: number;
70
+ lowestPitch: number;
71
+ };
72
+ /** Every position outside the note block, keyed by track. */
73
+ expressions: ReadonlyMap<number, string>;
74
+ }
75
+ export declare const describeTrackerBar: (spec: TrackerBarSpec) => TrackerBar;
package/lib/TrackerBar.js CHANGED
@@ -9,7 +9,7 @@ const areasOf = ({ notes, trackCount }) => [
9
9
  { role: 'treble-expression', from: notes.to + 1, to: trackCount }
10
10
  ];
11
11
  const scopeOf = (role) => role === 'bass-expression' ? 'bass' : 'treble';
12
- const describe = (spec) => {
12
+ export const describeTrackerBar = (spec) => {
13
13
  const areas = areasOf(spec);
14
14
  const roleOf = (track) => areas.find(area => track >= area.from && track <= area.to)?.role;
15
15
  const meaningOf = (track) => {
@@ -44,58 +44,3 @@ const describe = (spec) => {
44
44
  roleOf
45
45
  };
46
46
  };
47
- /**
48
- * The commands of the Welte-Mignon T-100, as its tracker bar reads them.
49
- */
50
- export const welteT100ExpressionTypes = [
51
- 'SustainPedalOn',
52
- 'SustainPedalOff',
53
- 'SoftPedalOn',
54
- 'SoftPedalOff',
55
- 'MezzoforteOff',
56
- 'MezzoforteOn',
57
- 'SlowCrescendoOn',
58
- 'SlowCrescendoOff',
59
- 'ForzandoOn',
60
- 'ForzandoOff',
61
- 'MotorOff',
62
- 'MotorOn',
63
- 'Rewind',
64
- 'ElectricCutOff'
65
- ];
66
- /**
67
- * Welte-Mignon T-100 ("red Welte"), cf. Hagmann, pp. 75 and 178.
68
- *
69
- * The note block spans 80 positions from C1 to g⁴, i.e. MIDI 24 to 103.
70
- * The expression valves are duplicated, bass below the note block and
71
- * treble above it, in mirrored order.
72
- */
73
- export const welteT100 = describe({
74
- id: 'welte-t100',
75
- name: 'Welte-Mignon T100',
76
- width: 328,
77
- trackCount: 100,
78
- notes: { from: 11, to: 90, lowestPitch: 24 },
79
- expressions: new Map([
80
- [1, 'MezzoforteOff'],
81
- [2, 'MezzoforteOn'],
82
- [3, 'SlowCrescendoOff'],
83
- [4, 'SlowCrescendoOn'],
84
- [5, 'ForzandoOff'],
85
- [6, 'ForzandoOn'],
86
- [7, 'SoftPedalOff'],
87
- [8, 'SoftPedalOn'],
88
- [9, 'MotorOff'],
89
- [10, 'MotorOn'],
90
- [91, 'Rewind'],
91
- [92, 'ElectricCutOff'],
92
- [93, 'SustainPedalOn'],
93
- [94, 'SustainPedalOff'],
94
- [95, 'ForzandoOn'],
95
- [96, 'ForzandoOff'],
96
- [97, 'SlowCrescendoOn'],
97
- [98, 'SlowCrescendoOff'],
98
- [99, 'MezzoforteOn'],
99
- [100, 'MezzoforteOff']
100
- ])
101
- });
@@ -0,0 +1,21 @@
1
+ import { ObjectAssumption } from "./Assumption";
2
+ import { AnyFeature } from "./Feature";
3
+ import { PaperStretch, RollCopy, Shift } from "./RollCopy";
4
+ import { TrackerBar } from "./TrackerBar";
5
+ export declare const applyShift: (shift: Shift, copy: RollCopy) => void;
6
+ export declare const applyStretch: (paperStretch: ObjectAssumption<PaperStretch>, copy: RollCopy) => void;
7
+ /** Takes the shift off the copy's features again, as far as one was applied. */
8
+ export declare const revertShift: (copy: RollCopy) => void;
9
+ /** Takes the stretch off the copy's features again, as far as one was applied. */
10
+ export declare const revertStretch: (copy: RollCopy) => void;
11
+ type AlignmentResult = {
12
+ /** Shift in mm, applied before the stretch. */
13
+ shift: number;
14
+ stretch: number;
15
+ };
16
+ /**
17
+ * Align two rolls by computing independent linear fits of each roll's note-onset positions
18
+ * using only the first and last segments, then deriving a transform x2 = (x1 + shift) * stretch.
19
+ */
20
+ export declare function alignFeatures(rollA: AnyFeature[], rollB: AnyFeature[], bar?: TrackerBar): AlignmentResult;
21
+ export {};
@@ -0,0 +1,117 @@
1
+ import { welteT100 } from "./systems/welteT100/bar";
2
+ export const applyShift = (shift, copy) => {
3
+ if (copy.ops.includes('shifted'))
4
+ return;
5
+ copy.features.forEach(feature => {
6
+ feature.horizontal.from += shift.horizontal;
7
+ if (feature.horizontal.to) {
8
+ feature.horizontal.to += shift.horizontal;
9
+ }
10
+ feature.vertical.from += shift.vertical;
11
+ if (feature.vertical.to) {
12
+ feature.vertical.to += shift.vertical;
13
+ }
14
+ });
15
+ copy.ops = [...copy.ops, 'shifted'];
16
+ copy.measurements.shift = shift;
17
+ };
18
+ export const applyStretch = (paperStretch, copy) => {
19
+ if (copy.ops.includes('stretched'))
20
+ return;
21
+ const stretch = paperStretch.factor;
22
+ copy.features.forEach(feature => {
23
+ feature.horizontal.from *= stretch;
24
+ if (feature.horizontal.to) {
25
+ feature.horizontal.to *= stretch;
26
+ }
27
+ });
28
+ copy.ops = [...copy.ops, 'stretched'];
29
+ copy.conditions.push(paperStretch);
30
+ };
31
+ /** Takes the shift off the copy's features again, as far as one was applied. */
32
+ export const revertShift = (copy) => {
33
+ const shift = copy.measurements.shift;
34
+ if (!copy.ops.includes('shifted') || !shift)
35
+ return;
36
+ copy.features.forEach(feature => {
37
+ feature.horizontal.from -= shift.horizontal;
38
+ if (feature.horizontal.to) {
39
+ feature.horizontal.to -= shift.horizontal;
40
+ }
41
+ feature.vertical.from -= shift.vertical;
42
+ if (feature.vertical.to) {
43
+ feature.vertical.to -= shift.vertical;
44
+ }
45
+ });
46
+ copy.ops = copy.ops.filter(op => op !== 'shifted');
47
+ delete copy.measurements.shift;
48
+ };
49
+ const isPaperStretch = (condition) => condition.conditionType === 'paper-stretch';
50
+ /** Takes the stretch off the copy's features again, as far as one was applied. */
51
+ export const revertStretch = (copy) => {
52
+ const stretch = copy.conditions.find(isPaperStretch);
53
+ if (!copy.ops.includes('stretched') || !stretch)
54
+ return;
55
+ copy.features.forEach(feature => {
56
+ feature.horizontal.from /= stretch.factor;
57
+ if (feature.horizontal.to) {
58
+ feature.horizontal.to /= stretch.factor;
59
+ }
60
+ });
61
+ copy.ops = copy.ops.filter(op => op !== 'stretched');
62
+ copy.conditions = copy.conditions.filter(condition => !isPaperStretch(condition));
63
+ };
64
+ const isNoteOn = (bar) => (feature) => {
65
+ return feature.type === 'Hole' && bar.meaningOf(feature.vertical.from)?.type === 'note';
66
+ };
67
+ /**
68
+ * Fit a line: position = alpha * index + beta via least squares.
69
+ */
70
+ function fitIndexToPosition(indices, positions) {
71
+ const n = indices.length;
72
+ const meanIdx = indices.reduce((s, i) => s + i, 0) / n;
73
+ const meanPos = positions.reduce((s, p) => s + p, 0) / n;
74
+ let num = 0;
75
+ let den = 0;
76
+ for (let i = 0; i < n; i++) {
77
+ const d = indices[i] - meanIdx;
78
+ num += d * (positions[i] - meanPos);
79
+ den += d * d;
80
+ }
81
+ const alpha = den === 0 ? 1 : num / den;
82
+ const beta = meanPos - alpha * meanIdx;
83
+ return { alpha, beta };
84
+ }
85
+ /**
86
+ * Selects the first and last N elements of an array (or fewer if length is smaller).
87
+ */
88
+ function selectEnds(arr, count) {
89
+ const n = arr.length;
90
+ if (count * 2 >= n)
91
+ return arr.slice();
92
+ return arr.slice(0, count).concat(arr.slice(n - count, n));
93
+ }
94
+ /**
95
+ * Align two rolls by computing independent linear fits of each roll's note-onset positions
96
+ * using only the first and last segments, then deriving a transform x2 = (x1 + shift) * stretch.
97
+ */
98
+ export function alignFeatures(rollA, rollB, bar = welteT100) {
99
+ // 1. Extract note-onset positions
100
+ const isNote = isNoteOn(bar);
101
+ const allXA = rollA.filter(isNote).map(f => f.horizontal.from);
102
+ const allXB = rollB.filter(isNote).map(f => f.horizontal.from);
103
+ // 2. Determine segment size (e.g. 10% of notes, min 5)
104
+ const segCount = Math.max(5, Math.floor(allXA.length * 0.1));
105
+ // 3. Select only first and last segments
106
+ const XA = selectEnds(allXA, segCount);
107
+ const idxA = XA.map((_, i) => i);
108
+ const XB = selectEnds(allXB, segCount);
109
+ const idxB = XB.map((_, i) => i);
110
+ // 4. Fit index->position for each roll on selected ends
111
+ const { alpha: alphaA, beta: betaA } = fitIndexToPosition(idxA, XA);
112
+ const { alpha: alphaB, beta: betaB } = fitIndexToPosition(idxB, XB);
113
+ // 5. Derive stretch and shift such that x2 = (x1 + shift) * stretch
114
+ const stretch = alphaB / alphaA;
115
+ const shift = betaB / stretch - betaA;
116
+ return { stretch, shift };
117
+ }
@@ -0,0 +1,27 @@
1
+ import { EditionView } from "./EditionView";
2
+ import { TrackerBar } from "./TrackerBar";
3
+ export type ConstraintProblem = {
4
+ version: string;
5
+ symbol: string;
6
+ problem: 'alignment-reference-missing' | 'before-reference-missing' | 'after-reference-missing' | 'placed-relative-to-itself' | 'placed-several-ways' | 'partner-missing' | 'paired-with-itself' | 'in-several-pairs' | 'pair-placed-on-both-sides';
7
+ };
8
+ /**
9
+ * Where the placements and pairings of the edition cannot hold as
10
+ * stated, version by version: a reference or partner absent from the
11
+ * version, a perforation placed relative to itself or in several ways
12
+ * at once, one claimed by several pairs, or a pair whose members are
13
+ * both placed and so cannot keep their distance and follow their
14
+ * references at once.
15
+ */
16
+ export declare const constraintProblems: (view: EditionView) => ConstraintProblem[];
17
+ export type UnknownExpressionType = {
18
+ version: string;
19
+ symbol: string;
20
+ expressionType: string;
21
+ };
22
+ /**
23
+ * The expressions of the edition whose type the tracker bar does not
24
+ * read, version by version. The roll names its system; a type from
25
+ * another system, or a misspelt one, means nothing on it.
26
+ */
27
+ export declare const unknownExpressionTypes: (view: EditionView, bar: TrackerBar) => UnknownExpressionType[];
@@ -0,0 +1,61 @@
1
+ import { idOf } from "./Assumption";
2
+ import { isPerforation, pairsAmong, placementsOf } from "./Symbol";
3
+ const missingReference = {
4
+ alignedWith: 'alignment-reference-missing',
5
+ before: 'before-reference-missing',
6
+ after: 'after-reference-missing'
7
+ };
8
+ const problemsIn = (version, perforations) => {
9
+ const ids = new Set(perforations.map(p => p.id));
10
+ const report = (symbol, problem) => ({ version, symbol, problem });
11
+ const missingReferences = perforations
12
+ .flatMap(p => placementsOf(p)
13
+ .filter(({ reference }) => !ids.has(idOf(reference)))
14
+ .map(({ relation }) => report(p.id, missingReference[relation])));
15
+ const selfPlaced = perforations
16
+ .filter(p => placementsOf(p).some(({ reference }) => idOf(reference) === p.id))
17
+ .map(p => report(p.id, 'placed-relative-to-itself'));
18
+ const placedSeveralWays = perforations
19
+ .filter(p => placementsOf(p).length > 1)
20
+ .map(p => report(p.id, 'placed-several-ways'));
21
+ const missingPartners = perforations
22
+ .filter(p => p.pairedWith && !ids.has(idOf(p.pairedWith)))
23
+ .map(p => report(p.id, 'partner-missing'));
24
+ const selfPaired = perforations
25
+ .filter(p => p.pairedWith && idOf(p.pairedWith) === p.id)
26
+ .map(p => report(p.id, 'paired-with-itself'));
27
+ const pairs = pairsAmong(perforations);
28
+ const pairsOf = (p) => pairs.filter(pair => pair.includes(p));
29
+ const inSeveralPairs = perforations
30
+ .filter(p => pairsOf(p).length > 1)
31
+ .map(p => report(p.id, 'in-several-pairs'));
32
+ const placedOnBothSides = pairs
33
+ .filter(([one, other]) => placementsOf(one).length > 0 && placementsOf(other).length > 0)
34
+ .flatMap(pair => pair.map(p => report(p.id, 'pair-placed-on-both-sides')));
35
+ return [
36
+ ...missingReferences, ...selfPlaced, ...placedSeveralWays,
37
+ ...missingPartners, ...selfPaired, ...inSeveralPairs, ...placedOnBothSides
38
+ ];
39
+ };
40
+ /**
41
+ * Where the placements and pairings of the edition cannot hold as
42
+ * stated, version by version: a reference or partner absent from the
43
+ * version, a perforation placed relative to itself or in several ways
44
+ * at once, one claimed by several pairs, or a pair whose members are
45
+ * both placed and so cannot keep their distance and follow their
46
+ * references at once.
47
+ */
48
+ export const constraintProblems = (view) => view.edition.versions.flatMap(version => problemsIn(version.id, view.snapshot(version.id).filter(isPerforation)));
49
+ const isExpression = (symbol) => symbol.type === 'expression';
50
+ /**
51
+ * The expressions of the edition whose type the tracker bar does not
52
+ * read, version by version. The roll names its system; a type from
53
+ * another system, or a misspelt one, means nothing on it.
54
+ */
55
+ export const unknownExpressionTypes = (view, bar) => {
56
+ const known = new Set(bar.expressionTypes);
57
+ return view.edition.versions.flatMap(version => view.snapshot(version.id)
58
+ .filter(isExpression)
59
+ .filter(symbol => !known.has(symbol.expressionType))
60
+ .map(symbol => ({ version: version.id, symbol: symbol.id, expressionType: symbol.expressionType })));
61
+ };