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.
@@ -0,0 +1,245 @@
1
+ import { geometryInMm, Grid, levelChanges, mezzoforteTravel, paperSeconds, pedalBrushing, pedalDefaults, ROWS_PER_MM, travelBetweenRails, WELTE_T98_SPOOL, } from "welte-mignon-emulator";
2
+ import { aperturePorts, CHAIN_GAP_MM, DERIVED, GENUINE, instrumentT98Of, labelOf, nuanceOf, PUNCH_T98_MM, pneumaticT98Model, rewindAt, runPedals, sforzandoPianoLift, STARTING_VALUES, TRACKER_BORE_T98_MM, } from "welte-mignon-emulator/t98";
3
+ import { welteT98 } from "./bar";
4
+ import { inCentimeters, mm, seconds, track } from "../../Quantity";
5
+ import { partitionPoint } from "../../sorted";
6
+ import { defaultVelocityMap, velocityOf } from "../velocity";
7
+ /**
8
+ * The instruments the emulator offers for the green Welte, in three groups that
9
+ * answer three different questions and must not be listed as one.
10
+ *
11
+ * A **genuine** instrument is fitted to the drawn nuance line of a green roll
12
+ * and says what a green Welte did. A **derived** instrument is fitted so that
13
+ * the green code of a recording reproduces what the fitted T-100 emulator makes
14
+ * of the *red* copy of the same recording, and says what Welte's editor meant
15
+ * the green roll to sound like; its provenance names the T-100 instrument it
16
+ * inherits, since it is only as good as the red reading behind it. The
17
+ * difference between the two, in the printed ordinate both scales share, is how
18
+ * far the transfer of a red reading onto the green mechanism succeeded.
19
+ *
20
+ * Neither has been fitted yet, so both groups are empty and what a playback runs
21
+ * on until then is the third group: the **unfitted** starting values, arithmetic
22
+ * from Welte's regulation controls and the T-100 consensus with no green roll
23
+ * behind any of it. A curve produced with them says so in its own `instrument`
24
+ * field, and nothing should be published from them.
25
+ */
26
+ export const instruments = { genuine: GENUINE, derived: DERIVED, unfitted: { 'starting-values': STARTING_VALUES } };
27
+ export const instrumentNames = [
28
+ ...Object.keys(GENUINE).map(genuine => ({ genuine })),
29
+ ...Object.keys(DERIVED).map(derived => ({ derived })),
30
+ { unfitted: 'starting-values' }
31
+ ];
32
+ const sameParameters = (a, b) => Object.keys(a).length === Object.keys(b).length
33
+ && Object.entries(a).every(([name, value]) => b[name] === value);
34
+ /** The instrument a pair of nuancing constants belongs to, if it is one. */
35
+ export const instrumentNameOf = (nuance) => instrumentNames.find(name => {
36
+ const instrument = instrumentT98Of(name);
37
+ return instrument !== undefined
38
+ && sameParameters(instrument.bass, nuance.bass)
39
+ && sameParameters(instrument.treble, nuance.treble);
40
+ });
41
+ export { instrumentT98Of, labelOf, nuanceOf };
42
+ /**
43
+ * The two readings of the pedal mechanism, which is the T-100's below the
44
+ * command: Hagmann has the valves and bellows that carry out the movements "in
45
+ * beiden Systemen dieselbe" (p. 106), and only the Vorpneumatik differs.
46
+ */
47
+ export const pedalPresets = {
48
+ damping: pedalDefaults,
49
+ brushing: pedalBrushing
50
+ };
51
+ export const pedalPresetOf = (pedals) => Object.keys(pedalPresets).find(name => sameParameters(pedalPresets[name], pedals));
52
+ export const defaultWelteT98Options = {
53
+ spool: WELTE_T98_SPOOL,
54
+ nuance: nuanceOf(STARTING_VALUES),
55
+ instrument: { unfitted: 'starting-values' },
56
+ pedals: pedalPresets.damping,
57
+ velocity: defaultVelocityMap,
58
+ pedalMode: 'continuous',
59
+ trackerBore: mm(TRACKER_BORE_T98_MM),
60
+ punchDiameter: mm(PUNCH_T98_MM),
61
+ chainGap: mm(CHAIN_GAP_MM),
62
+ division: track(52),
63
+ rewind: 'stop'
64
+ };
65
+ /**
66
+ * What each expression code operates, in the emulator's terms. There is no
67
+ * action: a T-98 function lasts exactly as long as its perforation, so a symbol
68
+ * carries its own extent and nothing cancels it. `SoftPedal` maps to the
69
+ * emulator's `hammerRail`, which keeps Hagmann's part name rather than the
70
+ * roll's function name.
71
+ */
72
+ const CODES = {
73
+ SforzandoPiano: 'sforzandoPiano',
74
+ SforzandoForte: 'sforzandoForte',
75
+ Mezzoforte: 'mezzoforte',
76
+ Crescendo: 'crescendo',
77
+ SustainPedal: 'sustainPedal',
78
+ SoftPedal: 'hammerRail'
79
+ };
80
+ const isWelteT98ExpressionType = (type) => Object.hasOwn(CODES, type);
81
+ const codeOf = (expressionType) => isWelteT98ExpressionType(expressionType) ? CODES[expressionType] : undefined;
82
+ /** Paper the grid runs on past the last hole, so that a final pedal release completes. */
83
+ const RUN_OUT = mm(100);
84
+ const isNote = (event) => event.type === 'note';
85
+ const isExpression = (event) => event.type === 'expression';
86
+ const paperOf = (toOwnPaper) => ({
87
+ rowOf: place => place * toOwnPaper * ROWS_PER_MM,
88
+ placeOfRow: row => mm(row / (toOwnPaper * ROWS_PER_MM)),
89
+ paperOfRow: row => mm(row / ROWS_PER_MM)
90
+ });
91
+ /** When the spool brings a place on the roll to the tracker bar. */
92
+ export const secondsAt = (spool, place) => seconds(paperSeconds(spool, inCentimeters(place)));
93
+ const halfOf = (note, division) => note.vertical.from >= division ? 'treble' : 'bass';
94
+ const readingOf = (paper) => (event) => {
95
+ const control = codeOf(event.expressionType);
96
+ if (!control)
97
+ return undefined;
98
+ const half = event.scope;
99
+ return {
100
+ event,
101
+ punch: {
102
+ half,
103
+ control,
104
+ rowOn: paper.rowOf(event.horizontal.from),
105
+ rowOff: paper.rowOf(event.horizontal.to)
106
+ }
107
+ };
108
+ };
109
+ const clamp = (value, low, high) => Math.min(Math.max(value, low), high);
110
+ /**
111
+ * One sample per row of the scan the constants were fitted on, from the
112
+ * beginning of the roll to a little past the last hole.
113
+ */
114
+ const gridOver = (events, spool, paper) => {
115
+ const last = mm(events.reduce((furthest, event) => Math.max(furthest, event.horizontal.to), 0));
116
+ const length = Math.ceil(paper.rowOf(last) + RUN_OUT * ROWS_PER_MM) + 1;
117
+ const times = new Float64Array(length).map((_, row) => secondsAt(spool, paper.paperOfRow(row)));
118
+ return new Grid(0, times);
119
+ };
120
+ const nuanceCurves = (grid, ports, samples, options) => {
121
+ const instrument = `Welte-Mignon T-98, ${labelOf(options.instrument)}`;
122
+ const curveOf = (half) => {
123
+ const params = options.nuance[half];
124
+ const output = pneumaticT98Model.run({ grid, half, ports }, params);
125
+ const travel = travelBetweenRails(output, params);
126
+ const hook = clamp(mezzoforteTravel(params), 0.01, 0.99);
127
+ return {
128
+ ...samples,
129
+ name: half,
130
+ kind: 'dynamics',
131
+ instrument,
132
+ travel,
133
+ velocity: travel.map(value => velocityOf(value, hook, options.velocity))
134
+ };
135
+ };
136
+ return { bass: curveOf('bass'), treble: curveOf('treble') };
137
+ };
138
+ const pedalCurves = (grid, ports, samples, options) => {
139
+ const travel = runPedals({ grid, ports }, options.pedals);
140
+ return {
141
+ damper: { ...samples, name: 'damper', kind: 'pedal', travel: travel.damper },
142
+ hammerRail: { ...samples, name: 'hammerRail', kind: 'pedal', travel: travel.hammerRail }
143
+ };
144
+ };
145
+ const performNotes = (events, grid, nuance, options, paper) => events
146
+ .filter(isNote)
147
+ .flatMap((note) => {
148
+ const curve = nuance[halfOf(note, options.division)];
149
+ const velocity = curve.velocity[grid.indexOfRow(paper.rowOf(note.horizontal.from))];
150
+ return [
151
+ { type: 'noteOn', performs: note, pitch: note.pitch, velocity, at: secondsAt(options.spool, note.horizontal.from) },
152
+ { type: 'noteOff', performs: note, pitch: note.pitch, velocity: 127, at: secondsAt(options.spool, note.horizontal.to) }
153
+ ];
154
+ });
155
+ /**
156
+ * The travel of one pedal as controller steps, each attributed to the last
157
+ * perforation of that pedal the tracker bar has reached.
158
+ */
159
+ const performPedal = (type, curve, grid, readings, mode) => {
160
+ if (readings.length === 0)
161
+ return [];
162
+ const ordered = readings.toSorted((a, b) => a.punch.rowOn - b.punch.rowOn);
163
+ const causeOf = (row) => ordered[Math.max(partitionPoint(ordered, reading => reading.punch.rowOn <= row) - 1, 0)].event;
164
+ return levelChanges(curve.travel, { mode })
165
+ .filter(change => change.index > 0)
166
+ .map(change => ({
167
+ type,
168
+ performs: causeOf(grid.rowAt(change.index)),
169
+ value: change.value,
170
+ at: seconds(curve.seconds[change.index])
171
+ }));
172
+ };
173
+ /**
174
+ * The row at which the Abstellbalg trips and the roll goes back, or the end of
175
+ * the grid where it never does. The shut-off hangs on the bass sforzando-piano
176
+ * valve and reads the same perforations the dynamic does, so this asks the
177
+ * emulator for that valve's lift and integrates it.
178
+ */
179
+ const rewindRow = (grid, ports, options) => {
180
+ if (options.rewind === 'ignore')
181
+ return grid.length;
182
+ const lift = sforzandoPianoLift({ grid, half: 'bass', ports }, options.nuance.bass);
183
+ return rewindAt(lift, grid.dt) ?? grid.length;
184
+ };
185
+ const truncated = (events, until) => events.filter(event => event.at <= until);
186
+ const cutCurve = (curve, rows) => ({
187
+ ...curve,
188
+ place: curve.place.slice(0, rows),
189
+ seconds: curve.seconds.slice(0, rows),
190
+ travel: curve.travel.slice(0, rows),
191
+ ...(curve.kind === 'dynamics' ? { velocity: curve.velocity.slice(0, rows) } : {})
192
+ });
193
+ /**
194
+ * The edition's tempo adjustment is left aside, as on the T-100: it is stated as
195
+ * a paper speed, and what the spool holds constant is its rate of revolution.
196
+ */
197
+ const perform = (events, options, roll) => {
198
+ const paper = paperOf(roll.toOwnPaper ?? 1);
199
+ const readings = events
200
+ .filter(isExpression)
201
+ .map(readingOf(paper))
202
+ .filter((reading) => reading !== undefined);
203
+ const grid = gridOver(events, options.spool, paper);
204
+ const geometry = geometryInMm(roll.punchDiameter ?? options.punchDiameter, options.trackerBore);
205
+ const gap = options.chainGap * ROWS_PER_MM;
206
+ const ports = aperturePorts(grid, readings.map(reading => reading.punch), geometry, gap);
207
+ const samples = {
208
+ place: grid.seconds.map((_, row) => paper.placeOfRow(row)),
209
+ seconds: grid.seconds
210
+ };
211
+ const nuance = nuanceCurves(grid, ports, samples, options);
212
+ const pedals = pedalCurves(grid, ports, samples, options);
213
+ const readingsOf = (control) => readings.filter(reading => reading.punch.control === control);
214
+ const stops = rewindRow(grid, ports, options);
215
+ const until = seconds(grid.seconds[Math.min(stops, grid.length - 1)]);
216
+ const rows = Math.min(stops + 1, grid.length);
217
+ return {
218
+ events: truncated([
219
+ ...performNotes(events, grid, nuance, options, paper),
220
+ ...performPedal('damper', pedals.damper, grid, readingsOf('sustainPedal'), options.pedalMode),
221
+ ...performPedal('hammerRail', pedals.hammerRail, grid, readingsOf('hammerRail'), options.pedalMode)
222
+ ], until),
223
+ curves: [nuance.bass, nuance.treble, pedals.damper, pedals.hammerRail].map(curve => cutCurve(curve, rows))
224
+ };
225
+ };
226
+ /**
227
+ * The green Welte, as welte-mignon-emulator models it. Everything downstream of
228
+ * the relay is the T-100's, on Hagmann's authority that the nuancing unit is
229
+ * built the same for both tracker scales (p. 96); what differs is in front of
230
+ * it. Each function is held for exactly as long as its own perforation runs
231
+ * over the glide block, four conduits stand on one bellows and their drives add
232
+ * as flows, the crescendo's ceiling is the balance of throttle 98 against the
233
+ * permanently open bore 100, and a long perforation on the bass sforzando-piano
234
+ * line sends the roll back.
235
+ *
236
+ * Its constants are **not fitted**. `instruments.genuine` and
237
+ * `instruments.derived` are empty until their fits run, and what a playback runs
238
+ * on until then is the unfitted starting values, which every curve says.
239
+ */
240
+ export const welteT98System = {
241
+ name: 'Welte-Mignon T98',
242
+ trackerBar: welteT98,
243
+ defaultOptions: defaultWelteT98Options,
244
+ perform
245
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linked-rolls",
3
- "version": "0.20.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": {
@@ -20,6 +20,14 @@
20
20
  "types": "./lib/systems/welteT100/system.d.ts",
21
21
  "default": "./lib/systems/welteT100/system.js"
22
22
  },
23
+ "./welte-t98": {
24
+ "types": "./lib/systems/welteT98/system.d.ts",
25
+ "default": "./lib/systems/welteT98/system.js"
26
+ },
27
+ "./welte-licensee": {
28
+ "types": "./lib/systems/welteLicensee/system.d.ts",
29
+ "default": "./lib/systems/welteLicensee/system.js"
30
+ },
23
31
  "./lib/*": "./lib/*",
24
32
  "./package.json": "./package.json"
25
33
  },
@@ -40,13 +48,13 @@
40
48
  "typescript": "^7.0.2",
41
49
  "vite": "^8.2.2",
42
50
  "vitest": "^5.0.0",
43
- "welte-t100-emulator": "^0.4.0"
51
+ "welte-mignon-emulator": "^1.0.0"
44
52
  },
45
53
  "peerDependencies": {
46
- "welte-t100-emulator": "^0.4.0"
54
+ "welte-mignon-emulator": "^1.0.0"
47
55
  },
48
56
  "peerDependenciesMeta": {
49
- "welte-t100-emulator": {
57
+ "welte-mignon-emulator": {
50
58
  "optional": true
51
59
  }
52
60
  },