linked-rolls 0.21.0 → 0.22.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/RollCopy.d.ts CHANGED
@@ -332,7 +332,9 @@ export declare const barOf: (copy: Pick<RollCopy, 'production'>) => TrackerBar;
332
332
  export declare function asSymbols(features: AnyFeature[], bar: TrackerBar): AnySymbol[];
333
333
  /**
334
334
  * The tracks a copy carries holes on that the tracker bar does not read.
335
- * A non-empty result usually means the scan is calibrated wrongly.
335
+ * A non-empty result usually means the scan is calibrated wrongly. A hole
336
+ * lying across several positions is counted against each one the bar
337
+ * cannot read, and not at all where it reads them all.
336
338
  */
337
339
  export declare function unreadTracks(features: AnyFeature[], bar: TrackerBar): Map<Track, number>;
338
340
  /**
package/lib/RollCopy.js CHANGED
@@ -40,30 +40,27 @@ export function asSymbols(features, bar) {
40
40
  const end = bar.endsAt(holes);
41
41
  return holes
42
42
  .filter(feature => end === undefined || feature.horizontal.from <= end.at)
43
- .flatMap((feature) => {
44
- const meaning = bar.meaningOf(feature.vertical.from);
45
- if (!meaning)
46
- return [];
47
- return [{
48
- id: `symbol_${v4()}`,
49
- ...meaning,
50
- carriers: [assignReference(feature.id)]
51
- }];
52
- });
43
+ // An opening across two positions uncovers both bar holes and so
44
+ // reads as both commands; one on a single position gives the one.
45
+ .flatMap((feature) => bar.meaningsOf(feature.vertical).map(meaning => ({
46
+ id: `symbol_${v4()}`,
47
+ ...meaning,
48
+ carriers: [assignReference(feature.id)]
49
+ })));
53
50
  }
54
51
  /**
55
52
  * The tracks a copy carries holes on that the tracker bar does not read.
56
- * A non-empty result usually means the scan is calibrated wrongly.
53
+ * A non-empty result usually means the scan is calibrated wrongly. A hole
54
+ * lying across several positions is counted against each one the bar
55
+ * cannot read, and not at all where it reads them all.
57
56
  */
58
57
  export function unreadTracks(features, bar) {
59
58
  const counts = new Map();
60
59
  features
61
60
  .filter(feature => feature.type === 'Hole')
62
- .filter(feature => !bar.meaningOf(feature.vertical.from))
63
- .forEach(feature => {
64
- const position = feature.vertical.from;
65
- counts.set(position, (counts.get(position) || 0) + 1);
66
- });
61
+ .flatMap(feature => bar.positionsIn(feature.vertical))
62
+ .filter(position => !bar.meaningOf(position))
63
+ .forEach(position => counts.set(position, (counts.get(position) || 0) + 1));
67
64
  return counts;
68
65
  }
69
66
  /**
@@ -67,8 +67,20 @@ export interface TrackerBar {
67
67
  * the symbols afterwards.
68
68
  */
69
69
  endsAt(features: readonly PlacedOnBar[]): RollEnd | undefined;
70
- /** `undefined` for a position the bar does not read. */
70
+ /**
71
+ * `undefined` for a position the bar does not read. A measured place
72
+ * is snapped to the nearest position: perforations sit on the grid and
73
+ * measurements of them scatter around it.
74
+ */
71
75
  meaningOf(position: Track): TrackMeaning | undefined;
76
+ /**
77
+ * What the bar reads off a feature, which may lie across more than one
78
+ * position. A perforation lifts every valve whose bar hole it uncovers,
79
+ * so an opening across two positions reads as two commands; one on a
80
+ * single position reads as the one `meaningOf` gives, and one on
81
+ * positions the bar does not read as none at all.
82
+ */
83
+ meaningsOf(span: OnBar): readonly TrackMeaning[];
72
84
  /**
73
85
  * `meaningOf` inverted: the position this bar reads the meaning on,
74
86
  * or `undefined` where it does not read it at all. A symbol's track
@@ -76,8 +88,10 @@ export interface TrackerBar {
76
88
  * sits on exactly one position of a given bar.
77
89
  */
78
90
  positionOf(meaning: TrackMeaning): Track | undefined;
79
- /** `undefined` for a position the bar does not read. */
91
+ /** `undefined` for a position the bar does not read; snapped as `meaningOf` is. */
80
92
  roleOf(position: Track): TrackRole | undefined;
93
+ /** The positions a feature lies across, snapped to the grid. */
94
+ positionsIn(span: OnBar): readonly Track[];
81
95
  }
82
96
  /**
83
97
  * What a position says, as a key. Two bars read the same thing exactly
@@ -118,6 +132,15 @@ export type PlacedOnBar = {
118
132
  readonly from: Track;
119
133
  };
120
134
  };
135
+ /**
136
+ * Where a feature lies across the bar: one place, or a run of them where
137
+ * `to` is given. Structural, so a feature's `vertical` passes as it is and
138
+ * this module need know nothing about features.
139
+ */
140
+ export type OnBar = {
141
+ readonly from: Track;
142
+ readonly to?: Track;
143
+ };
121
144
  export interface TrackerBarSpec {
122
145
  id: string;
123
146
  name: string;
package/lib/TrackerBar.js CHANGED
@@ -20,24 +20,42 @@ const areasOf = ({ notes, trackCount }) => [
20
20
  { role: 'treble-expression', from: track(notes.to + 1), to: track(trackCount) }
21
21
  ];
22
22
  const scopeOf = (role) => role === 'bass-expression' ? 'bass' : 'treble';
23
+ /**
24
+ * The bar position a measured place falls on. Places are measured off a
25
+ * scan and scatter around the grid, while the bar has holes only at whole
26
+ * positions, so the nearest one is the one uncovered.
27
+ */
28
+ const snap = (place) => track(Math.round(place));
29
+ /** The positions a span reaches, both ends snapped and the run between them. */
30
+ const positionsBetween = (span) => {
31
+ const ends = [snap(span.from), snap(span.to ?? span.from)];
32
+ const [first, last] = [Math.min(...ends), Math.max(...ends)];
33
+ return Array.from({ length: last - first + 1 }, (_, step) => track(first + step));
34
+ };
23
35
  export const describeTrackerBar = (spec) => {
24
36
  const areas = areasOf(spec);
25
- const roleOf = (position) => areas.find(area => position >= area.from && position <= area.to)?.role;
37
+ const areaAt = (position) => areas.find(area => position >= area.from && position <= area.to)?.role;
38
+ const roleOf = (position) => areaAt(snap(position));
26
39
  const meaningOf = (position) => {
27
- const role = roleOf(position);
40
+ const place = snap(position);
41
+ const role = areaAt(place);
28
42
  if (!role)
29
43
  return undefined;
30
44
  if (role === 'note') {
31
45
  return {
32
46
  type: 'note',
33
- pitch: position - spec.notes.from + spec.notes.lowestPitch
47
+ pitch: place - spec.notes.from + spec.notes.lowestPitch
34
48
  };
35
49
  }
36
- const expressionType = spec.expressions.get(position);
50
+ const expressionType = spec.expressions.get(place);
37
51
  if (!expressionType)
38
52
  return undefined;
39
53
  return { type: 'expression', expressionType, scope: scopeOf(role) };
40
54
  };
55
+ const meaningsOf = (span) => positionsBetween(span).flatMap(position => {
56
+ const meaning = meaningOf(position);
57
+ return meaning ? [meaning] : [];
58
+ });
41
59
  const positions = new Map(Array.from({ length: spec.trackCount }, (_, i) => track(i + 1))
42
60
  .flatMap(position => {
43
61
  const meaning = meaningOf(position);
@@ -54,7 +72,7 @@ export const describeTrackerBar = (spec) => {
54
72
  const endsAt = (features) => {
55
73
  const hold = spec.rewindHold ?? mm(0);
56
74
  const candidates = features
57
- .filter(feature => feature.vertical.from === rewind)
75
+ .filter(feature => snap(feature.vertical.from) === rewind)
58
76
  .filter(feature => !shared || feature.horizontal.to - feature.horizontal.from >= hold)
59
77
  .map(feature => feature.horizontal.from);
60
78
  return candidates.length ? { at: mm(Math.min(...candidates)), because: 'rewind' } : undefined;
@@ -70,8 +88,10 @@ export const describeTrackerBar = (spec) => {
70
88
  rewindTrack: track(rewind),
71
89
  ...(spec.paperSpeed && { paperSpeed: spec.paperSpeed }),
72
90
  meaningOf,
91
+ meaningsOf,
73
92
  positionOf: meaning => positions.get(keyOf(meaning)),
74
- roleOf
93
+ roleOf,
94
+ positionsIn: positionsBetween
75
95
  };
76
96
  };
77
97
  /**
package/lib/alignment.js CHANGED
@@ -59,8 +59,9 @@ const noteOnsets = (features, bar) => features
59
59
  .flatMap((feature) => {
60
60
  if (feature.type !== 'Hole')
61
61
  return [];
62
- const meaning = bar.meaningOf(feature.vertical.from);
63
- return meaning?.type === 'note' ? [{ pitch: meaning.pitch, at: feature.horizontal.from }] : [];
62
+ return bar.meaningsOf(feature.vertical)
63
+ .filter(meaning => meaning.type === 'note')
64
+ .map(meaning => ({ pitch: meaning.pitch, at: feature.horizontal.from }));
64
65
  })
65
66
  .sort(byPlace);
66
67
  const median = (values) => {
@@ -79,8 +79,10 @@ const carriersOffTheirMeaning = (view, version, perforations) => {
79
79
  const copy = view.copyOf(carrier.id);
80
80
  if (!copy)
81
81
  return false;
82
- const meaning = barOf(copy).meaningOf(carrier.vertical.from);
83
- return !meaning || keyOf(meaning) !== keyOf(symbol);
82
+ // A carrier lying across several positions reads as several commands,
83
+ // and carries the symbol as long as one of them is the symbol's.
84
+ return !barOf(copy).meaningsOf(carrier.vertical)
85
+ .some(meaning => keyOf(meaning) === keyOf(symbol));
84
86
  };
85
87
  return perforations
86
88
  .filter(symbol => view.carriersOf(symbol).some(carrier => misread(carrier, symbol)))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linked-rolls",
3
- "version": "0.21.0",
3
+ "version": "0.22.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": {