linked-rolls 0.17.0 → 0.19.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.
@@ -83,6 +83,13 @@ export interface TrackerBarSpec {
83
83
  };
84
84
  /** Every position outside the note block, keyed by track. */
85
85
  expressions: ReadonlyMap<number, string>;
86
+ /**
87
+ * The position the rewind runs on, where the system gives it no
88
+ * valve of its own to be named after. The T-98 drives its rewind
89
+ * with a long perforation on the bass sforzando-piano position.
90
+ * Left out, it is the position typed `Rewind`.
91
+ */
92
+ rewindTrack?: number;
86
93
  /** The speed the system runs its rolls at, where the literature states one. */
87
94
  paperSpeed?: SpeedMeasure;
88
95
  }
package/lib/TrackerBar.js CHANGED
@@ -28,8 +28,8 @@ export const describeTrackerBar = (spec) => {
28
28
  return undefined;
29
29
  return { type: 'expression', expressionType, scope: scopeOf(role) };
30
30
  };
31
- const rewind = [...spec.expressions]
32
- .find(([, type]) => type === 'Rewind')?.[0];
31
+ const rewind = spec.rewindTrack
32
+ ?? [...spec.expressions].find(([, type]) => type === 'Rewind')?.[0];
33
33
  if (rewind === undefined) {
34
34
  throw new Error(`${spec.name} declares no rewind track`);
35
35
  }
@@ -24,12 +24,18 @@ export interface AlignmentResult {
24
24
  }
25
25
  /**
26
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.
27
+ * of `rollB`, reading each through its own bar. Runs of pitches that
28
+ * occur once on each roll anchor a first line; then every note is paired
29
+ * with its nearest counterpart of the same pitch and the line refitted,
30
+ * the window closing each time. A copy cut for another paper speed, a
31
+ * scan with a calibration pattern, and holes the other copy lacks are
32
+ * all within reach of that. Nothing is found where the rolls share no
33
+ * run of pitches, as between two different pieces.
34
+ *
35
+ * Two bars are what lets a copy cut for one system be aligned against a
36
+ * copy cut for another, a T-98 against a T-100: the bars put the note
37
+ * positions on different tracks, but they agree on the pitch each track
38
+ * sounds, and it is the pitches that are matched. Where a copy's holes
39
+ * have already been put onto the edition's bar, that bar reads it.
34
40
  */
35
- export declare function alignFeatures(rollA: readonly AnyFeature[], rollB: readonly AnyFeature[], bar?: TrackerBar): AlignmentResult | undefined;
41
+ export declare function alignFeatures(rollA: readonly AnyFeature[], rollB: readonly AnyFeature[], barA?: TrackerBar, barB?: TrackerBar): AlignmentResult | undefined;
package/lib/alignment.js CHANGED
@@ -159,17 +159,23 @@ const resultOf = ({ line, matches }) => {
159
159
  };
160
160
  /**
161
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.
162
+ * of `rollB`, reading each through its own bar. Runs of pitches that
163
+ * occur once on each roll anchor a first line; then every note is paired
164
+ * with its nearest counterpart of the same pitch and the line refitted,
165
+ * the window closing each time. A copy cut for another paper speed, a
166
+ * scan with a calibration pattern, and holes the other copy lacks are
167
+ * all within reach of that. Nothing is found where the rolls share no
168
+ * run of pitches, as between two different pieces.
169
+ *
170
+ * Two bars are what lets a copy cut for one system be aligned against a
171
+ * copy cut for another, a T-98 against a T-100: the bars put the note
172
+ * positions on different tracks, but they agree on the pitch each track
173
+ * sounds, and it is the pitches that are matched. Where a copy's holes
174
+ * have already been put onto the edition's bar, that bar reads it.
169
175
  */
170
- export function alignFeatures(rollA, rollB, bar = welteT100) {
171
- const a = noteOnsets(rollA, bar);
172
- const b = noteOnsets(rollB, bar);
176
+ export function alignFeatures(rollA, rollB, barA = welteT100, barB = barA) {
177
+ const a = noteOnsets(rollA, barA);
178
+ const b = noteOnsets(rollB, barB);
173
179
  const coarse = robustLine(anchors(a, b));
174
180
  if (!coarse)
175
181
  return undefined;
package/lib/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export * from './TrackCalibration';
12
12
  export * from './TrackerBar';
13
13
  export * from './systems/welteT100/bar';
14
14
  export * from './systems/welteLicensee/bar';
15
+ export * from './systems/welteT98/bar';
15
16
  export * from './systems';
16
17
  export * from './ReproducingSystem';
17
18
  export * from './FeatureSource';
@@ -29,4 +30,5 @@ export * from './asJsonLd';
29
30
  export * from './importJsonLd';
30
31
  export * from './migrate';
31
32
  export { readFromStanfordAton, type StanfordAtonOptions } from './readers/stanfordAton';
33
+ export { readFromPhillipsEroll, CONTROL_OFFSET, phillipsSystems, type PhillipsErollOptions } from './readers/phillipsEroll';
32
34
  export { readFromSpencerBar, readSpencerAnn, paperSpeedOfSpencerAnn, SPENCER_ROWS_PER_INCH, type SpencerBarOptions } from './readers/spencerBar';
package/lib/index.js CHANGED
@@ -12,6 +12,7 @@ export * from './TrackCalibration';
12
12
  export * from './TrackerBar';
13
13
  export * from './systems/welteT100/bar';
14
14
  export * from './systems/welteLicensee/bar';
15
+ export * from './systems/welteT98/bar';
15
16
  export * from './systems';
16
17
  export * from './ReproducingSystem';
17
18
  export * from './FeatureSource';
@@ -29,4 +30,5 @@ export * from './asJsonLd';
29
30
  export * from './importJsonLd';
30
31
  export * from './migrate';
31
32
  export { readFromStanfordAton } from './readers/stanfordAton';
33
+ export { readFromPhillipsEroll, CONTROL_OFFSET, phillipsSystems } from './readers/phillipsEroll';
32
34
  export { readFromSpencerBar, readSpencerAnn, paperSpeedOfSpencerAnn, SPENCER_ROWS_PER_INCH } from './readers/spencerBar';
@@ -0,0 +1,62 @@
1
+ import { RollCopy } from "../RollCopy";
2
+ import { TrackerBar } from "../TrackerBar";
3
+ import { Millimeters, Seconds } from "../Quantity";
4
+ /**
5
+ * Peter Phillips's "e-roll" file, the unprocessed output of his
6
+ * pneumatic roll reader (thesis pp. 182–196): the roll runs over a
7
+ * tracker bar and a switch on every position writes a MIDI note as its
8
+ * perforation passes. Every position is there, expression included, all
9
+ * at one velocity. His other product, the standard MIDI file, has the
10
+ * expression decoded into velocities already and cannot be read as a
11
+ * roll.
12
+ *
13
+ * Two things follow from a reader rather than a scanner. The events are
14
+ * elapsed time, not paper, and the take-up spool accelerates the paper
15
+ * as it fills, so `placeAt` has to put the time back onto the paper.
16
+ * And a switch stays open longer than its perforation is long, so a
17
+ * hole read here runs past the punched one.
18
+ */
19
+ /**
20
+ * He numbers his files by two rules at once, which his free samples
21
+ * show and which follow from what the file is for. A note carries the
22
+ * MIDI number it sounds, so that the file plays on a MIDI instrument
23
+ * without translation. An expression position, having no pitch to
24
+ * carry, is numbered by its place on the bar plus a fixed offset: on a
25
+ * red e-roll the sustain commands sit at 107 and 108 for positions 93
26
+ * and 94, and the soft pedal at 21 and 22 for positions 7 and 8, so the
27
+ * offset is fourteen.
28
+ *
29
+ * The two rules collide on one number, the lowest note of the T-100
30
+ * sharing 24 with the tenth position. Notes win there, the note block
31
+ * being the larger claim; none of his samples uses the number at all.
32
+ */
33
+ export declare const CONTROL_OFFSET: Readonly<Record<string, number>>;
34
+ export interface PhillipsErollOptions {
35
+ /**
36
+ * The bar the file numbers its positions by, which is the bar of
37
+ * the roll he read.
38
+ */
39
+ system?: TrackerBar;
40
+ /** The edition's bar, onto which the positions are put. */
41
+ bar?: TrackerBar;
42
+ /**
43
+ * How far above its position an expression number sits. Defaults to
44
+ * what his samples show for that bar.
45
+ */
46
+ controlOffset?: number;
47
+ /**
48
+ * Where on the paper the roll had run after so many seconds. The
49
+ * default is the constant speed the system states, which ignores
50
+ * the take-up spool; pass `paperAt` of welte-t100-emulator to
51
+ * account for it.
52
+ */
53
+ placeAt?: (elapsed: Seconds) => Millimeters;
54
+ }
55
+ /**
56
+ * Reads one of his e-roll files as a copy of the roll, in millimetres
57
+ * of paper. A position the edition's bar does not read is left out, as
58
+ * the bar would leave it.
59
+ */
60
+ export declare function readFromPhillipsEroll(buffer: ArrayBuffer, { system, bar, controlOffset, placeAt }?: PhillipsErollOptions): RollCopy;
61
+ /** The bars his files are known to be numbered by. */
62
+ export declare const phillipsSystems: readonly TrackerBar[];
@@ -0,0 +1,164 @@
1
+ import { read } from "midifile-ts";
2
+ import { v4 } from "uuid";
3
+ import { assignObject } from "../Assumption";
4
+ import { systemOf, translationBetween } from "../TrackerBar";
5
+ import { welteT100 } from "../systems/welteT100/bar";
6
+ import { welteLicensee } from "../systems/welteLicensee/bar";
7
+ import { inMetersPerMinute, mm, seconds, track } from "../Quantity";
8
+ /**
9
+ * Peter Phillips's "e-roll" file, the unprocessed output of his
10
+ * pneumatic roll reader (thesis pp. 182–196): the roll runs over a
11
+ * tracker bar and a switch on every position writes a MIDI note as its
12
+ * perforation passes. Every position is there, expression included, all
13
+ * at one velocity. His other product, the standard MIDI file, has the
14
+ * expression decoded into velocities already and cannot be read as a
15
+ * roll.
16
+ *
17
+ * Two things follow from a reader rather than a scanner. The events are
18
+ * elapsed time, not paper, and the take-up spool accelerates the paper
19
+ * as it fills, so `placeAt` has to put the time back onto the paper.
20
+ * And a switch stays open longer than its perforation is long, so a
21
+ * hole read here runs past the punched one.
22
+ */
23
+ /**
24
+ * He numbers his files by two rules at once, which his free samples
25
+ * show and which follow from what the file is for. A note carries the
26
+ * MIDI number it sounds, so that the file plays on a MIDI instrument
27
+ * without translation. An expression position, having no pitch to
28
+ * carry, is numbered by its place on the bar plus a fixed offset: on a
29
+ * red e-roll the sustain commands sit at 107 and 108 for positions 93
30
+ * and 94, and the soft pedal at 21 and 22 for positions 7 and 8, so the
31
+ * offset is fourteen.
32
+ *
33
+ * The two rules collide on one number, the lowest note of the T-100
34
+ * sharing 24 with the tenth position. Notes win there, the note block
35
+ * being the larger claim; none of his samples uses the number at all.
36
+ */
37
+ export const CONTROL_OFFSET = {
38
+ 'welte-t100': 14,
39
+ 'welte-licensee': 15
40
+ };
41
+ const DEFAULT_TEMPO = 500000;
42
+ const isNoteOn = (event) => event.type === 'channel' && event.subtype === 'noteOn' && event.velocity > 0;
43
+ const isNoteOff = (event) => event.type === 'channel' && (event.subtype === 'noteOff' || (event.subtype === 'noteOn' && event.velocity === 0));
44
+ /** The events of every track on one clock, since a note may end on another track than it began. */
45
+ const onOneClock = (tracks) => tracks
46
+ .flatMap(events => {
47
+ let at = 0;
48
+ return events.map(event => ({ at: at += event.deltaTime, event }));
49
+ })
50
+ .sort((a, b) => a.at - b.at);
51
+ const temposIn = (timed) => {
52
+ const changes = timed.flatMap(({ at, event }) => event.type === 'meta' && event.subtype === 'setTempo'
53
+ ? [{ at, microsecondsPerBeat: event.microsecondsPerBeat }]
54
+ : []);
55
+ return changes.length > 0 && changes[0].at === 0
56
+ ? changes
57
+ : [{ at: 0, microsecondsPerBeat: DEFAULT_TEMPO }, ...changes];
58
+ };
59
+ /**
60
+ * Turns a tick into elapsed seconds, following the tempo changes. His
61
+ * files carry one tempo, but a file written by hand may carry several.
62
+ */
63
+ const clockOf = (tempos, ticksPerBeat) => {
64
+ /** Microseconds elapsed at the start of each tempo, the first being zero. */
65
+ const elapsedBefore = tempos.map((_, index) => tempos
66
+ .slice(0, index)
67
+ .reduce((total, tempo, at) => total + (tempos[at + 1].at - tempo.at) * tempo.microsecondsPerBeat, 0));
68
+ return (tick) => {
69
+ const index = Math.max(tempos.findLastIndex(tempo => tempo.at <= tick), 0);
70
+ const microseconds = elapsedBefore[index] + (tick - tempos[index].at) * tempos[index].microsecondsPerBeat;
71
+ return seconds(microseconds / ticksPerBeat / 1000000);
72
+ };
73
+ };
74
+ /** Pairs note-on with the next note-off of the same pitch. */
75
+ const notesIn = (timed) => {
76
+ const open = new Map();
77
+ const notes = [];
78
+ timed.forEach(({ at, event }) => {
79
+ if (event.type !== 'channel' || !('noteNumber' in event))
80
+ return;
81
+ const pitch = event.noteNumber;
82
+ if (isNoteOn(event)) {
83
+ open.set(pitch, [...(open.get(pitch) ?? []), at]);
84
+ }
85
+ else if (isNoteOff(event)) {
86
+ const [from, ...rest] = open.get(pitch) ?? [];
87
+ if (from === undefined)
88
+ return;
89
+ open.set(pitch, rest);
90
+ notes.push({ pitch, from, to: at });
91
+ }
92
+ });
93
+ return notes.sort((a, b) => a.from - b.from || a.pitch - b.pitch);
94
+ };
95
+ /** Paper run at the constant speed the system states, for a bar that states one. */
96
+ const atStatedSpeed = (system) => {
97
+ const speed = system.paperSpeed;
98
+ if (!speed) {
99
+ throw new Error(`Phillips e-roll: the ${system.name} states no paper speed, so pass placeAt to say where the paper had run`);
100
+ }
101
+ const perSecond = inMetersPerMinute(speed) * 1000 / 60;
102
+ return (elapsed) => mm(elapsed * perSecond);
103
+ };
104
+ const controlOffsetOf = (system, given) => {
105
+ const measured = given ?? CONTROL_OFFSET[system.id];
106
+ if (measured === undefined) {
107
+ throw new Error(`Phillips e-roll: no control offset is known for the ${system.name}, so pass controlOffset to say how far above its position an expression number sits`);
108
+ }
109
+ return measured;
110
+ };
111
+ /** The position each note of the bar sounds from. */
112
+ const positionsByPitch = (bar) => new Map(Array.from({ length: bar.trackCount }, (_, index) => track(index + 1))
113
+ .flatMap((position) => {
114
+ const meaning = bar.meaningOf(position);
115
+ return meaning?.type === 'note' ? [[meaning.pitch, position]] : [];
116
+ }));
117
+ /**
118
+ * Reads one of his e-roll files as a copy of the roll, in millimetres
119
+ * of paper. A position the edition's bar does not read is left out, as
120
+ * the bar would leave it.
121
+ */
122
+ export function readFromPhillipsEroll(buffer, { system = welteT100, bar = welteT100, controlOffset, placeAt } = {}) {
123
+ const file = read(new Uint8Array(buffer));
124
+ const timed = onOneClock(file.tracks);
125
+ const elapsedAt = clockOf(temposIn(timed), file.header.ticksPerBeat);
126
+ const place = placeAt ?? atStatedSpeed(system);
127
+ const offset = controlOffsetOf(system, controlOffset);
128
+ const notePositions = positionsByPitch(system);
129
+ const onBar = translationBetween(system, bar);
130
+ const positionOf = (number) => notePositions.get(number) ?? track(number - offset);
131
+ const features = notesIn(timed).flatMap((note) => {
132
+ const position = onBar(positionOf(note.pitch));
133
+ if (position === undefined)
134
+ return [];
135
+ return [{
136
+ type: 'Hole',
137
+ id: v4(),
138
+ vertical: { from: position, unit: 'track' },
139
+ horizontal: {
140
+ unit: 'mm',
141
+ from: place(elapsedAt(note.from)),
142
+ to: place(elapsedAt(note.to))
143
+ }
144
+ }];
145
+ });
146
+ return {
147
+ type: 'RollCopy',
148
+ id: v4(),
149
+ ops: [],
150
+ conditions: [],
151
+ keeper: { name: '', sameAs: [] },
152
+ measurements: {},
153
+ production: { system: systemOf(system) },
154
+ modifications: [],
155
+ features,
156
+ readFrom: {
157
+ kind: 'recording',
158
+ actor: assignObject({ name: 'Phillips, Peter', sameAs: [] }),
159
+ note: 'Read on Phillips’s pneumatic roll reader. The places are elapsed time put back onto the paper, and a hole runs as long as its switch stayed open, which is longer than the perforation.'
160
+ }
161
+ };
162
+ }
163
+ /** The bars his files are known to be numbered by. */
164
+ export const phillipsSystems = [welteT100, welteLicensee];
@@ -1,8 +1,9 @@
1
1
  import { systemIdOf } from "../TrackerBar";
2
2
  import { welteT100 } from "./welteT100/bar";
3
3
  import { welteLicensee } from "./welteLicensee/bar";
4
+ import { welteT98 } from "./welteT98/bar";
4
5
  /** The tracker bars the library knows, the T-100 first as the usual one. */
5
- export const trackerBars = [welteT100, welteLicensee];
6
+ export const trackerBars = [welteT100, welteLicensee, welteT98];
6
7
  /** The bar of a system the type vocabulary knows, from its concept. */
7
8
  export const trackerBarOf = (system) => {
8
9
  const id = systemIdOf(system);
@@ -0,0 +1,31 @@
1
+ import { TrackerBar } from "../../TrackerBar";
2
+ /**
3
+ * The commands of the Welte-Mignon T-98, as its tracker bar reads them.
4
+ *
5
+ * They are not the T-100's. Where the T-100 latches a function on with
6
+ * one perforation and cancels it with a second, the T-98 holds it for
7
+ * as long as one continuous perforation lasts (Hagmann, pp. 89 f. and
8
+ * 100–103; Phillips, p. 121), so none of these is an On or an Off. The
9
+ * two sforzando valves name the end of the range they pull towards.
10
+ */
11
+ export declare const welteT98ExpressionTypes: readonly ['SforzandoPiano', 'SforzandoForte', 'Mezzoforte', 'Crescendo', 'SustainPedal', 'SoftPedal'];
12
+ export type WelteT98ExpressionType = typeof welteT98ExpressionTypes[number];
13
+ /**
14
+ * Welte-Mignon T-98 ("green Welte"), cf. Hagmann, Anhang 11 (p. 179)
15
+ * and Phillips, p. 121.
16
+ *
17
+ * 98 positions at nine to the inch on paper 286 mm wide, five expression
18
+ * valves on each side and 88 note positions between them, A0 to c⁵ (MIDI
19
+ * 21 to 108). A roll re-cut from a Mignon master uses only the middle 80
20
+ * of those, C1 to g⁴, which is the T-100's compass.
21
+ *
22
+ * The rewind is not a valve of its own: a long perforation on the bass
23
+ * sforzando-piano position drives it, which is why that position is
24
+ * named for the valve and the rewind stated separately.
25
+ *
26
+ * Measured against Julian Dyer's scan of the T-98 copy of roll 225
27
+ * (Grünfeld, Träumerei): 98 columns 2.818 mm apart on paper 285.6 mm
28
+ * wide, notes on the middle 80 columns, the sustain valve punched 53
29
+ * times where the T-100 copy latches its sustain on 53 times.
30
+ */
31
+ export declare const welteT98: TrackerBar;
@@ -0,0 +1,57 @@
1
+ import { describeTrackerBar } from "../../TrackerBar";
2
+ import { mm } from "../../Quantity";
3
+ /**
4
+ * The commands of the Welte-Mignon T-98, as its tracker bar reads them.
5
+ *
6
+ * They are not the T-100's. Where the T-100 latches a function on with
7
+ * one perforation and cancels it with a second, the T-98 holds it for
8
+ * as long as one continuous perforation lasts (Hagmann, pp. 89 f. and
9
+ * 100–103; Phillips, p. 121), so none of these is an On or an Off. The
10
+ * two sforzando valves name the end of the range they pull towards.
11
+ */
12
+ export const welteT98ExpressionTypes = [
13
+ 'SforzandoPiano',
14
+ 'SforzandoForte',
15
+ 'Mezzoforte',
16
+ 'Crescendo',
17
+ 'SustainPedal',
18
+ 'SoftPedal'
19
+ ];
20
+ /**
21
+ * Welte-Mignon T-98 ("green Welte"), cf. Hagmann, Anhang 11 (p. 179)
22
+ * and Phillips, p. 121.
23
+ *
24
+ * 98 positions at nine to the inch on paper 286 mm wide, five expression
25
+ * valves on each side and 88 note positions between them, A0 to c⁵ (MIDI
26
+ * 21 to 108). A roll re-cut from a Mignon master uses only the middle 80
27
+ * of those, C1 to g⁴, which is the T-100's compass.
28
+ *
29
+ * The rewind is not a valve of its own: a long perforation on the bass
30
+ * sforzando-piano position drives it, which is why that position is
31
+ * named for the valve and the rewind stated separately.
32
+ *
33
+ * Measured against Julian Dyer's scan of the T-98 copy of roll 225
34
+ * (Grünfeld, Träumerei): 98 columns 2.818 mm apart on paper 285.6 mm
35
+ * wide, notes on the middle 80 columns, the sustain valve punched 53
36
+ * times where the T-100 copy latches its sustain on 53 times.
37
+ */
38
+ export const welteT98 = describeTrackerBar({
39
+ id: 'welte-green',
40
+ name: 'Welte-Mignon T98',
41
+ width: mm(286),
42
+ trackCount: 98,
43
+ notes: { from: 6, to: 93, lowestPitch: 21 },
44
+ rewindTrack: 1,
45
+ expressions: new Map([
46
+ [1, 'SforzandoPiano'],
47
+ [2, 'Mezzoforte'],
48
+ [3, 'SustainPedal'],
49
+ [4, 'Crescendo'],
50
+ [5, 'SforzandoForte'],
51
+ [94, 'SforzandoForte'],
52
+ [95, 'Crescendo'],
53
+ [96, 'SoftPedal'],
54
+ [97, 'Mezzoforte'],
55
+ [98, 'SforzandoPiano']
56
+ ])
57
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linked-rolls",
3
- "version": "0.17.0",
3
+ "version": "0.19.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": {