linked-rolls 0.20.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/README.md CHANGED
@@ -79,7 +79,7 @@ tracker bar and a `perform` function; the core does not depend on any one
79
79
  instrument's model.
80
80
 
81
81
  The first system is the red Welte, `linked-rolls/welte-t100`, built on
82
- [welte-t100-emulator](https://github.com/pfefferniels/welte-t100): the
82
+ [welte-mignon-emulator](https://github.com/pfefferniels/welte-t100): the
83
83
  take-up spool sets the time axis, the Nuancierbälge fill through their
84
84
  conduits and are arrested by the Mezzoforte pin, and the two pedals travel
85
85
  rather than switch. The nuancing constants come as `instruments`: the
@@ -103,11 +103,36 @@ emulation.emulateVersion(version, view)
103
103
  const midi = emulation.asMIDI()
104
104
  ```
105
105
 
106
- The emulator is an optional peer dependency: an application that uses the
107
- T-100 system installs `welte-t100-emulator` itself, and one that only
108
- reads editions does not need it. For development on both at once, `npm
109
- link` a checkout of [welte-t100](https://github.com/pfefferniels/welte-t100)
110
- into this repository.
106
+ The second is the green Welte, `linked-rolls/welte-t98`. Everything
107
+ downstream of the relay is the same mechanism — Hagmann has the nuancing
108
+ unit built the same for both tracker scales (p. 96) and what differs is
109
+ in front of it: each function lasts exactly as long as its own perforation
110
+ runs over the glide block rather than latching until a cancel line is read,
111
+ four conduits stand on one bellows and their drives add as flows, the
112
+ crescendo's ceiling is the balance of its throttle against a permanently
113
+ open bleed rather than a cap, the two pedals sit on the opposite edges of
114
+ the paper, and a long perforation on the bass sforzando-piano line sends
115
+ the roll back. Its constants are **not fitted**: `instruments.genuine` and
116
+ `instruments.derived` are empty until their fits are run, and until then a
117
+ playback runs on unfitted starting values, which every curve says in its
118
+ own `instrument` field.
119
+
120
+ Both systems share one velocity map, and `DynamicsCurve.travel` means the
121
+ same thing on both: the position on the printed ordinate of Welte's own
122
+ ruled band, 0 at that half's P.P. gridline and 1 at the shared F.F. line.
123
+ That is what lets a red issue and a green issue of one recording be
124
+ compared at all, since the band is ruled the same way on both.
125
+
126
+ ```ts
127
+ import { welteT98System } from 'linked-rolls/welte-t98'
128
+ ```
129
+
130
+ The emulator is an optional peer dependency: an application that uses one
131
+ of the Welte systems installs `welte-mignon-emulator` itself, and one that
132
+ only reads editions does not need it. For development on both at once,
133
+ `npm link` a checkout of
134
+ [welte-t100](https://github.com/pfefferniels/welte-t100) into this
135
+ repository.
111
136
 
112
137
  ### Alignment, order and pairing
113
138
 
package/lib/Emulation.js CHANGED
@@ -9,9 +9,10 @@ const punchDiameterOf = (view) => {
9
9
  .filter((value) => value !== undefined && value > 0);
10
10
  return measured.length > 0 ? mean(measured) : undefined;
11
11
  };
12
- const propertiesOf = (view) => ({
12
+ const propertiesOf = (view, version) => ({
13
13
  punchDiameter: punchDiameterOf(view),
14
- tempo: view.edition.tempoAdjustment
14
+ tempo: view.edition.tempoAdjustment,
15
+ toOwnPaper: view.toOwnPaperOf(version)
15
16
  });
16
17
  /** The onset a symbol has on each copy carrying it, by the copy's index, as the mean of its holes there. */
17
18
  const onsetsByCopy = (view, symbolId) => {
@@ -163,7 +164,7 @@ export class Emulation {
163
164
  return this.midiEvents;
164
165
  }
165
166
  this.applyConstraints(view);
166
- const performance = this.system.perform(this.negotiatedEvents, this.options, propertiesOf(view));
167
+ const performance = this.system.perform(this.negotiatedEvents, this.options, propertiesOf(view, version));
167
168
  this.curves = performance.curves;
168
169
  const onsets = performance.events.filter(event => event.type === 'noteOn').map(event => event.at);
169
170
  const origin = skipToFirstNote ? earliestOf(onsets) : seconds(0);
@@ -63,8 +63,18 @@ interface CurveSamples {
63
63
  readonly place: Float64Array;
64
64
  readonly seconds: Float64Array;
65
65
  /**
66
- * Position between the two ends of a travel: 0 with a bellows open and
67
- * 1 with it closed, 0 with a pedal up and 1 with it down.
66
+ * Position on the printed ordinate of the roll's own ruled band: 0 at that
67
+ * half's P.P. gridline, 1 at the shared F.F. line, with M.F. at 0.5. For a
68
+ * pedal, 0 with it up and 1 with it down.
69
+ *
70
+ * Both Welte scales rule that band the same way — five rails, "P.P. M.F.
71
+ * F.F. M.F. P.P.", three named levels a side with the centre F.F. shared,
72
+ * measuring 20.0 mm between rails on red 3309 and on green Welte 184 alike —
73
+ * so a curve from one system and a curve from the other are in one unit, on
74
+ * Welte's authority rather than on ours. That is what lets a red issue and a
75
+ * green issue of one recording be compared at all. It is a unit of *drawn
76
+ * deflection*: whether the deflection is linear in the bellows' own travel is
77
+ * open, and is what the emulator's `scaleWarp` exists to answer.
68
78
  */
69
79
  readonly travel: Float64Array;
70
80
  }
@@ -72,6 +82,14 @@ interface CurveSamples {
72
82
  export type DynamicsCurve = CurveSamples & {
73
83
  readonly kind: 'dynamics';
74
84
  readonly velocity: Float64Array;
85
+ /**
86
+ * The instrument whose constants produced it, by name. A comparison plot
87
+ * must not be able to put two curves side by side without saying which
88
+ * instrument each came from: a T-98 fitted to a green roll's drawn line and
89
+ * a T-98 fitted to a red copy's curve answer different questions, and their
90
+ * difference is the point of the comparison.
91
+ */
92
+ readonly instrument: string;
75
93
  };
76
94
  export type PedalCurve = CurveSamples & {
77
95
  readonly kind: 'pedal';
@@ -83,6 +101,24 @@ export type RollProperties = {
83
101
  punchDiameter?: Millimeters;
84
102
  /** The tempo the edition adjusts the roll to, where it states one. */
85
103
  tempo?: RollTempo;
104
+ /**
105
+ * Place on the edition's shared axis × this = millimetres of the version's
106
+ * own paper. About 0.775 for a green issue of a red recording, 1 for a
107
+ * version on the axis it was measured on, which is the default.
108
+ *
109
+ * Versions of several systems share one place axis, so a green version's
110
+ * places are in red millimetres and would play about 29 % long if the
111
+ * spool read them as green paper. The factor is the inverse of the scale
112
+ * `alignCopy` recorded when it put the copy on the shared axis, taken from
113
+ * the copies of the version's own system: red copies carry a green
114
+ * version's notes too and say nothing about green paper.
115
+ *
116
+ * It scales **places only**. Punch diameter, the chain gap and the tracker
117
+ * bore are measured on the version's own paper already, and scaling them
118
+ * as well would put the aperture model out by the factor with nothing
119
+ * failing loudly.
120
+ */
121
+ toOwnPaper?: number;
86
122
  };
87
123
  export type Performance = {
88
124
  readonly events: readonly AnyPerformedRollFeature[];
package/lib/RollCopy.d.ts CHANGED
@@ -320,11 +320,21 @@ export declare const barOf: (copy: Pick<RollCopy, 'production'>) => TrackerBar;
320
320
  *
321
321
  * The bar is named rather than defaulted: a copy is read by its own
322
322
  * bar, and reading a green copy with the red one is a silent semitone.
323
+ *
324
+ * Reading stops where the bar says the roll's content ends. What a
325
+ * scanner punched past the rewind is not the roll speaking: Julian
326
+ * Dyer's scan of the green 225 carries a staircase of test punches
327
+ * across nearly every track after its rewind, which read on as symbols
328
+ * would be 318 notes nobody played. The features stay on the copy,
329
+ * since they are really on the paper and are his calibration of his own
330
+ * scan; it is the reading that stops.
323
331
  */
324
332
  export declare function asSymbols(features: AnyFeature[], bar: TrackerBar): AnySymbol[];
325
333
  /**
326
334
  * The tracks a copy carries holes on that the tracker bar does not read.
327
- * 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.
328
338
  */
329
339
  export declare function unreadTracks(features: AnyFeature[], bar: TrackerBar): Map<Track, number>;
330
340
  /**
package/lib/RollCopy.js CHANGED
@@ -26,34 +26,41 @@ export const barOf = (copy) => trackerBarOf(copy.production?.system) ?? welteT10
26
26
  *
27
27
  * The bar is named rather than defaulted: a copy is read by its own
28
28
  * bar, and reading a green copy with the red one is a silent semitone.
29
+ *
30
+ * Reading stops where the bar says the roll's content ends. What a
31
+ * scanner punched past the rewind is not the roll speaking: Julian
32
+ * Dyer's scan of the green 225 carries a staircase of test punches
33
+ * across nearly every track after its rewind, which read on as symbols
34
+ * would be 318 notes nobody played. The features stay on the copy,
35
+ * since they are really on the paper and are his calibration of his own
36
+ * scan; it is the reading that stops.
29
37
  */
30
38
  export function asSymbols(features, bar) {
31
- return features
32
- .filter(feature => feature.type === 'Hole')
33
- .flatMap((feature) => {
34
- const meaning = bar.meaningOf(feature.vertical.from);
35
- if (!meaning)
36
- return [];
37
- return [{
38
- id: `symbol_${v4()}`,
39
- ...meaning,
40
- carriers: [assignReference(feature.id)]
41
- }];
42
- });
39
+ const holes = features.filter(feature => feature.type === 'Hole');
40
+ const end = bar.endsAt(holes);
41
+ return holes
42
+ .filter(feature => end === undefined || feature.horizontal.from <= end.at)
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
+ })));
43
50
  }
44
51
  /**
45
52
  * The tracks a copy carries holes on that the tracker bar does not read.
46
- * 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.
47
56
  */
48
57
  export function unreadTracks(features, bar) {
49
58
  const counts = new Map();
50
59
  features
51
60
  .filter(feature => feature.type === 'Hole')
52
- .filter(feature => !bar.meaningOf(feature.vertical.from))
53
- .forEach(feature => {
54
- const position = feature.vertical.from;
55
- counts.set(position, (counts.get(position) || 0) + 1);
56
- });
61
+ .flatMap(feature => bar.positionsIn(feature.vertical))
62
+ .filter(position => !bar.meaningOf(position))
63
+ .forEach(position => counts.set(position, (counts.get(position) || 0) + 1));
57
64
  return counts;
58
65
  }
59
66
  /**
@@ -56,8 +56,31 @@ export interface TrackerBar {
56
56
  * of their own, as the Licensee's do, states none.
57
57
  */
58
58
  readonly paperSpeed?: SpeedMeasure;
59
- /** `undefined` for a position the bar does not read. */
59
+ /**
60
+ * Where the roll's own content ends, from its perforations alone, or
61
+ * `undefined` where nothing on the paper says. A copy that reads
62
+ * `undefined` runs to the end of whatever was scanned, which is a fact
63
+ * about the scan rather than about the roll and should be reported as one.
64
+ *
65
+ * Takes anything with a place and a position, so a reader can ask it of a
66
+ * copy's features before there are symbols, and a collation can ask it of
67
+ * the symbols afterwards.
68
+ */
69
+ endsAt(features: readonly PlacedOnBar[]): RollEnd | undefined;
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
+ */
60
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[];
61
84
  /**
62
85
  * `meaningOf` inverted: the position this bar reads the meaning on,
63
86
  * or `undefined` where it does not read it at all. A symbol's track
@@ -65,8 +88,10 @@ export interface TrackerBar {
65
88
  * sits on exactly one position of a given bar.
66
89
  */
67
90
  positionOf(meaning: TrackMeaning): Track | undefined;
68
- /** `undefined` for a position the bar does not read. */
91
+ /** `undefined` for a position the bar does not read; snapped as `meaningOf` is. */
69
92
  roleOf(position: Track): TrackRole | undefined;
93
+ /** The positions a feature lies across, snapped to the grid. */
94
+ positionsIn(span: OnBar): readonly Track[];
70
95
  }
71
96
  /**
72
97
  * What a position says, as a key. Two bars read the same thing exactly
@@ -85,6 +110,37 @@ export declare const systemIdOf: (system: Concept | undefined) => string | undef
85
110
  * in the bar's own 1-based numbering; `describeTrackerBar` gives them
86
111
  * their type.
87
112
  */
113
+ /**
114
+ * Where a roll's own content ends, as far as its perforations say.
115
+ *
116
+ * This is a question about the paper, not about the mechanism: it asks where
117
+ * the rewind is *punched*, not when the rewind pneumatic takes hold. The second
118
+ * is a matter of valve lift and belongs to whatever performs the roll. Keeping
119
+ * them apart is what lets collation and counting ask this without an emulator.
120
+ */
121
+ export type RollEnd = {
122
+ readonly at: Millimeters;
123
+ readonly because: 'rewind';
124
+ };
125
+ /** Anything carrying a place on the roll and a position on the bar. */
126
+ export type PlacedOnBar = {
127
+ readonly horizontal: {
128
+ readonly from: Millimeters;
129
+ readonly to: Millimeters;
130
+ };
131
+ readonly vertical: {
132
+ readonly from: Track;
133
+ };
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
+ };
88
144
  export interface TrackerBarSpec {
89
145
  id: string;
90
146
  name: string;
@@ -107,6 +163,13 @@ export interface TrackerBarSpec {
107
163
  rewindTrack?: number;
108
164
  /** The speed the system runs its rolls at, where the literature states one. */
109
165
  paperSpeed?: SpeedMeasure;
166
+ /**
167
+ * How long a perforation on a *shared* rewind position has to be before it
168
+ * is the rewind rather than the command the position usually carries. Only
169
+ * meaningful with `rewindTrack`: a system that gives the rewind a line of
170
+ * its own needs no threshold, since anything there is the rewind.
171
+ */
172
+ rewindHold?: Millimeters;
110
173
  }
111
174
  export declare const describeTrackerBar: (spec: TrackerBarSpec) => TrackerBar;
112
175
  /**
package/lib/TrackerBar.js CHANGED
@@ -1,4 +1,4 @@
1
- import { track } from "./Quantity";
1
+ import { mm, track } from "./Quantity";
2
2
  /**
3
3
  * What a position says, as a key. Two bars read the same thing exactly
4
4
  * where their keys agree, which is what lets a symbol cross from one
@@ -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);
@@ -48,18 +66,32 @@ export const describeTrackerBar = (spec) => {
48
66
  if (rewind === undefined) {
49
67
  throw new Error(`${spec.name} declares no rewind track`);
50
68
  }
69
+ // A rewind on a line of its own is unambiguous; one sharing a line is only
70
+ // the rewind when it is far longer than that line's usual command.
71
+ const shared = spec.rewindTrack !== undefined;
72
+ const endsAt = (features) => {
73
+ const hold = spec.rewindHold ?? mm(0);
74
+ const candidates = features
75
+ .filter(feature => snap(feature.vertical.from) === rewind)
76
+ .filter(feature => !shared || feature.horizontal.to - feature.horizontal.from >= hold)
77
+ .map(feature => feature.horizontal.from);
78
+ return candidates.length ? { at: mm(Math.min(...candidates)), because: 'rewind' } : undefined;
79
+ };
51
80
  return {
52
81
  id: spec.id,
53
82
  name: spec.name,
54
83
  width: spec.width,
55
84
  trackCount: spec.trackCount,
85
+ endsAt,
56
86
  areas,
57
87
  expressionTypes: [...new Set(spec.expressions.values())],
58
88
  rewindTrack: track(rewind),
59
89
  ...(spec.paperSpeed && { paperSpeed: spec.paperSpeed }),
60
90
  meaningOf,
91
+ meaningsOf,
61
92
  positionOf: meaning => positions.get(keyOf(meaning)),
62
- roleOf
93
+ roleOf,
94
+ positionsIn: positionsBetween
63
95
  };
64
96
  };
65
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/lib/context.d.ts CHANGED
@@ -8,3 +8,5 @@ export { default as welteT100JsonLdContext } from './spec/welte-t100.context.jso
8
8
  * rather than minting a second set of IRIs for the same commands.
9
9
  */
10
10
  export { default as welteLicenseeJsonLdContext } from './spec/welte-licensee.context.json';
11
+ /** The context of the Welte-Mignon T-98, added to an edition of a green roll. */
12
+ export { default as welteT98JsonLdContext } from './spec/welte-t98.context.json';
package/lib/context.js CHANGED
@@ -8,3 +8,5 @@ export { default as welteT100JsonLdContext } from './spec/welte-t100.context.jso
8
8
  * rather than minting a second set of IRIs for the same commands.
9
9
  */
10
10
  export { default as welteLicenseeJsonLdContext } from './spec/welte-licensee.context.json';
11
+ /** The context of the Welte-Mignon T-98, added to an edition of a green roll. */
12
+ export { default as welteT98JsonLdContext } from './spec/welte-t98.context.json';
@@ -45,7 +45,7 @@ export interface PhillipsErollOptions {
45
45
  /**
46
46
  * Where on the paper the roll had run after so many seconds. The
47
47
  * default is the constant speed the system states, which ignores
48
- * the take-up spool; pass `paperAt` of welte-t100-emulator to
48
+ * the take-up spool; pass `paperAt` of welte-mignon-emulator to
49
49
  * account for it.
50
50
  */
51
51
  placeAt?: (elapsed: Seconds) => Millimeters;
@@ -0,0 +1,10 @@
1
+ {
2
+ "@context": {
3
+ "@version": 1.1,
4
+ "expressionType": {
5
+ "@id": "http://www.cidoc-crm.org/cidoc-crm/P2_has_type",
6
+ "@type": "@vocab",
7
+ "@context": { "@vocab": "https://w3id.org/reo/type/welte-green/" }
8
+ }
9
+ }
10
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * How bellows travel becomes a MIDI velocity.
3
+ *
4
+ * Shared by every Welte system rather than declared per system, and that is a
5
+ * decision about comparability rather than about accuracy. The point of running
6
+ * a red issue and a green issue of one recording through two emulators is to
7
+ * attribute the difference between them to the coding. If the two systems used
8
+ * two velocity maps, the difference between the maps would dominate and the
9
+ * comparison would measure the maps.
10
+ *
11
+ * Nothing in either mechanism determines the map. These three anchors are
12
+ * midi2exp's published velocities, joined linearly with the middle one at the
13
+ * Mezzoforte pin of the half in question, and they are what the T-100's
14
+ * published results were produced with. Two independent implementations
15
+ * disagree with them and may well be right: Stahnke gives `v_h = K_s √(w − W_s)`
16
+ * for vacuum to hammer velocity and `V_MIDI = round(52 + 25 log₂ v_h)` for
17
+ * velocity to MIDI, and Phillips and Gosden's *Rollmidi*, in the same volume,
18
+ * uses an explicitly logarithmic curve; Phillips's own bench measurement has the
19
+ * suction-to-velocity relation "essentially logarithmic" (pp. 215 f.). The
20
+ * default should change only once a red/green comparison has been run both ways
21
+ * and the difference measured, and then for both systems at once.
22
+ */
23
+ export type VelocityMap = {
24
+ piano: number;
25
+ mezzoforte: number;
26
+ forte: number;
27
+ };
28
+ /** midi2exp's three velocities, which is what the T-100's published results used. */
29
+ export declare const defaultVelocityMap: VelocityMap;
30
+ /**
31
+ * The map joined linearly through its three anchors, with the middle one at the
32
+ * Mezzoforte pin of the half in question.
33
+ */
34
+ export declare const velocityOf: (travel: number, hook: number, map: VelocityMap) => number;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * How bellows travel becomes a MIDI velocity.
3
+ *
4
+ * Shared by every Welte system rather than declared per system, and that is a
5
+ * decision about comparability rather than about accuracy. The point of running
6
+ * a red issue and a green issue of one recording through two emulators is to
7
+ * attribute the difference between them to the coding. If the two systems used
8
+ * two velocity maps, the difference between the maps would dominate and the
9
+ * comparison would measure the maps.
10
+ *
11
+ * Nothing in either mechanism determines the map. These three anchors are
12
+ * midi2exp's published velocities, joined linearly with the middle one at the
13
+ * Mezzoforte pin of the half in question, and they are what the T-100's
14
+ * published results were produced with. Two independent implementations
15
+ * disagree with them and may well be right: Stahnke gives `v_h = K_s √(w − W_s)`
16
+ * for vacuum to hammer velocity and `V_MIDI = round(52 + 25 log₂ v_h)` for
17
+ * velocity to MIDI, and Phillips and Gosden's *Rollmidi*, in the same volume,
18
+ * uses an explicitly logarithmic curve; Phillips's own bench measurement has the
19
+ * suction-to-velocity relation "essentially logarithmic" (pp. 215 f.). The
20
+ * default should change only once a red/green comparison has been run both ways
21
+ * and the difference measured, and then for both systems at once.
22
+ */
23
+ /** midi2exp's three velocities, which is what the T-100's published results used. */
24
+ export const defaultVelocityMap = { piano: 35, mezzoforte: 60, forte: 90 };
25
+ const clamp = (value, low, high) => Math.min(Math.max(value, low), high);
26
+ /**
27
+ * The map joined linearly through its three anchors, with the middle one at the
28
+ * Mezzoforte pin of the half in question.
29
+ */
30
+ export const velocityOf = (travel, hook, map) => {
31
+ const position = clamp(travel, 0, 1);
32
+ if (position <= hook) {
33
+ return map.piano + (position / hook) * (map.mezzoforte - map.piano);
34
+ }
35
+ return map.mezzoforte + ((position - hook) / (1 - hook)) * (map.forte - map.mezzoforte);
36
+ };
@@ -0,0 +1,46 @@
1
+ import { ReproducingSystem } from "../../ReproducingSystem";
2
+ import { type WelteT100Options } from "../welteT100/system";
3
+ export type WelteLicenseeOptions = WelteT100Options;
4
+ /**
5
+ * The one instrument on offer, and it is not a Licensee instrument.
6
+ *
7
+ * These are the T-100 consensus constants, fitted across the hand-drawn
8
+ * nuance lines of six rolls played on Freiburg instruments. Nothing here was
9
+ * measured on a Licensee: no Licensee roll is known to carry a drawn line, no
10
+ * Licensee instrument has been fitted, and the published prior art does not
11
+ * agree with itself — the same 186 ms is a part stroke in midi2exp and a whole
12
+ * stroke in pianolatron, a factor of 2.2.
13
+ *
14
+ * So a Licensee playback is a Freiburg reading of American paper, and the
15
+ * union has one arm to keep that visible. Nothing should be published from it.
16
+ */
17
+ export declare const instruments: {
18
+ unfitted: {
19
+ 'welte-t100-consensus': import("welte-mignon-emulator/t100").Instrument;
20
+ };
21
+ };
22
+ export type WelteLicenseeInstrumentName = {
23
+ unfitted: 'welte-t100-consensus';
24
+ };
25
+ export declare const instrumentNames: readonly WelteLicenseeInstrumentName[];
26
+ /**
27
+ * Welte's own spool is carried over with the constants. Phillips (p. 181) says
28
+ * Licensee rolls "play at a range of paper speeds", so there is no one figure
29
+ * to put here and the red geometry is a placeholder rather than a reading.
30
+ * Where an edition states a tempo of its own it is the better authority, and
31
+ * the spool is better varied than trusted: it scales every conductance by k
32
+ * and every time constant by 1/k, and touches nothing dimensionless.
33
+ */
34
+ export declare const defaultWelteLicenseeOptions: WelteLicenseeOptions;
35
+ /**
36
+ * Welte-Mignon (Licensee), the American re-cut. It keeps the T-100's
37
+ * lock-and-cancel coding on 98 tracks — which is what separates it from the
38
+ * green, the coding rule rather than the track count (Phillips, Table 4.3) —
39
+ * so it reads the same commands and is played by the same mechanism.
40
+ *
41
+ * What it does not have is an instrument of its own. The constants are the
42
+ * T-100's, the spool is the T-100's, and neither has ever been checked against
43
+ * a Licensee. `instruments` therefore offers a single `unfitted` arm, and every
44
+ * curve produced here names itself as such.
45
+ */
46
+ export declare const welteLicenseeSystem: ReproducingSystem<WelteLicenseeOptions>;
@@ -0,0 +1,60 @@
1
+ import { CONSENSUS } from "welte-mignon-emulator/t100";
2
+ import { track } from "../../Quantity";
3
+ import { welteLicensee } from "./bar";
4
+ import { defaultWelteT100Options, instrumentNameOf, nuanceOf, performAs } from "../welteT100/system";
5
+ /**
6
+ * The one instrument on offer, and it is not a Licensee instrument.
7
+ *
8
+ * These are the T-100 consensus constants, fitted across the hand-drawn
9
+ * nuance lines of six rolls played on Freiburg instruments. Nothing here was
10
+ * measured on a Licensee: no Licensee roll is known to carry a drawn line, no
11
+ * Licensee instrument has been fitted, and the published prior art does not
12
+ * agree with itself — the same 186 ms is a part stroke in midi2exp and a whole
13
+ * stroke in pianolatron, a factor of 2.2.
14
+ *
15
+ * So a Licensee playback is a Freiburg reading of American paper, and the
16
+ * union has one arm to keep that visible. Nothing should be published from it.
17
+ */
18
+ export const instruments = { unfitted: { 'welte-t100-consensus': CONSENSUS } };
19
+ export const instrumentNames = [
20
+ { unfitted: 'welte-t100-consensus' }
21
+ ];
22
+ /**
23
+ * Welte's own spool is carried over with the constants. Phillips (p. 181) says
24
+ * Licensee rolls "play at a range of paper speeds", so there is no one figure
25
+ * to put here and the red geometry is a placeholder rather than a reading.
26
+ * Where an edition states a tempo of its own it is the better authority, and
27
+ * the spool is better varied than trusted: it scales every conductance by k
28
+ * and every time constant by 1/k, and touches nothing dimensionless.
29
+ */
30
+ export const defaultWelteLicenseeOptions = {
31
+ ...defaultWelteT100Options,
32
+ nuance: nuanceOf(CONSENSUS),
33
+ /**
34
+ * Two positions lower than the T-100's 54: the Licensee drops the two
35
+ * motor tracks, so the note block and the treble valves sit two places in
36
+ * (Hagmann p. 40 f., Phillips p. 123).
37
+ */
38
+ division: track(52)
39
+ };
40
+ /** Never the bare name of a T-100 instrument: a curve from here has to say what it ran on. */
41
+ const licenseeInstrument = (nuance) => instrumentNameOf(nuance) === 'consensus'
42
+ ? 'Welte-Mignon (Licensee), unfitted — Welte-Mignon T-100 consensus'
43
+ : 'Welte-Mignon (Licensee), custom';
44
+ /**
45
+ * Welte-Mignon (Licensee), the American re-cut. It keeps the T-100's
46
+ * lock-and-cancel coding on 98 tracks — which is what separates it from the
47
+ * green, the coding rule rather than the track count (Phillips, Table 4.3) —
48
+ * so it reads the same commands and is played by the same mechanism.
49
+ *
50
+ * What it does not have is an instrument of its own. The constants are the
51
+ * T-100's, the spool is the T-100's, and neither has ever been checked against
52
+ * a Licensee. `instruments` therefore offers a single `unfitted` arm, and every
53
+ * curve produced here names itself as such.
54
+ */
55
+ export const welteLicenseeSystem = {
56
+ name: 'Welte-Mignon (Licensee)',
57
+ trackerBar: welteLicensee,
58
+ defaultOptions: defaultWelteLicenseeOptions,
59
+ perform: performAs(licenseeInstrument)
60
+ };