linked-rolls 0.10.1 → 0.11.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/Collation.d.ts +1 -1
- package/lib/Collation.js +53 -14
- package/lib/EditionView.d.ts +11 -0
- package/lib/EditionView.js +78 -82
- package/lib/Emulation.js +10 -16
- package/lib/Version.d.ts +5 -0
- package/lib/Version.js +4 -0
- package/lib/constraints.js +4 -2
- package/lib/editionOps.js +59 -31
- package/lib/importJsonLd.d.ts +3 -1
- package/lib/importJsonLd.js +22 -42
- package/lib/index.d.ts +1 -1
- package/lib/index.js +1 -1
- package/lib/migrate.js +17 -5
- package/lib/readers/spencerBar.d.ts +18 -0
- package/lib/readers/spencerBar.js +120 -0
- package/lib/readers/stanfordAton.js +6 -6
- package/lib/sorted.d.ts +6 -0
- package/lib/sorted.js +17 -0
- package/lib/systems/welteT100/system.js +5 -4
- package/package.json +1 -1
- package/lib/readers/midiSpans.d.ts +0 -25
- package/lib/readers/midiSpans.js +0 -112
- package/lib/readers/spencerMidi.d.ts +0 -27
- package/lib/readers/spencerMidi.js +0 -60
package/lib/Collation.d.ts
CHANGED
|
@@ -26,5 +26,5 @@ export type Collation = {
|
|
|
26
26
|
symbol: Readonly<AnySymbol>;
|
|
27
27
|
counterpart: Readonly<AnySymbol>;
|
|
28
28
|
};
|
|
29
|
-
/** Each of the own symbols with every inherited symbol it collates with. */
|
|
29
|
+
/** Each of the own symbols with every inherited symbol it collates with, both in the order given. */
|
|
30
30
|
export declare const collationsOf: (own: readonly Readonly<AnySymbol>[], inherited: readonly Readonly<AnySymbol>[], locate: Locate, tolerance?: CollationTolerance) => Collation[];
|
package/lib/Collation.js
CHANGED
|
@@ -1,26 +1,65 @@
|
|
|
1
1
|
import { distance, mm } from "./Quantity";
|
|
2
|
+
import { partitionPoint } from "./sorted";
|
|
2
3
|
export const defaultCollationTolerance = { toleranceStart: mm(5), toleranceEnd: mm(5) };
|
|
4
|
+
/**
|
|
5
|
+
* What a symbol says, as a key: the pitch of a note, the type and scope
|
|
6
|
+
* of an expression. Symbols collate within one key only.
|
|
7
|
+
*/
|
|
8
|
+
const kindOf = (symbol) => {
|
|
9
|
+
switch (symbol.type) {
|
|
10
|
+
case 'note': return `note ${symbol.pitch}`;
|
|
11
|
+
case 'expression': return `expression ${symbol.scope} ${symbol.expressionType}`;
|
|
12
|
+
case 'text': return 'text';
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
const nearby = (here, there, tolerance) => distance(here.from, there.from) <= tolerance.toleranceStart
|
|
16
|
+
&& distance(here.to, there.to) <= tolerance.toleranceEnd;
|
|
3
17
|
/**
|
|
4
18
|
* Two symbols collate when they are of one kind, say the same thing
|
|
5
19
|
* (pitch, or expression type and scope), and lie at about the same
|
|
6
20
|
* place along the roll.
|
|
7
21
|
*/
|
|
8
22
|
export const isCollatable = (a, b, locate, tolerance = defaultCollationTolerance) => {
|
|
9
|
-
if (a
|
|
10
|
-
return false;
|
|
11
|
-
if (a.type === 'note' && b.type === 'note' && a.pitch !== b.pitch)
|
|
12
|
-
return false;
|
|
13
|
-
if (a.type === 'expression' && b.type === 'expression'
|
|
14
|
-
&& (a.expressionType !== b.expressionType || a.scope !== b.scope))
|
|
23
|
+
if (kindOf(a) !== kindOf(b))
|
|
15
24
|
return false;
|
|
16
25
|
const here = locate(a)?.horizontal;
|
|
17
26
|
const there = locate(b)?.horizontal;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
return here !== undefined && there !== undefined && nearby(here, there, tolerance);
|
|
28
|
+
};
|
|
29
|
+
const placed = (symbols, locate) => symbols.flatMap((symbol, index) => {
|
|
30
|
+
const horizontal = locate(symbol)?.horizontal;
|
|
31
|
+
return horizontal ? [{ symbol, index, horizontal }] : [];
|
|
32
|
+
});
|
|
33
|
+
const groupBy = (items, keyOf) => items.reduce((groups, item) => {
|
|
34
|
+
const key = keyOf(item);
|
|
35
|
+
const group = groups.get(key);
|
|
36
|
+
if (group)
|
|
37
|
+
group.push(item);
|
|
38
|
+
else
|
|
39
|
+
groups.set(key, [item]);
|
|
40
|
+
return groups;
|
|
41
|
+
}, new Map());
|
|
42
|
+
/** The placed symbols by kind, each kind in order of onset. */
|
|
43
|
+
const byKindInOrderOfOnset = (symbols) => {
|
|
44
|
+
const groups = groupBy(symbols, ({ symbol }) => kindOf(symbol));
|
|
45
|
+
groups.forEach(group => group.sort((a, b) => a.horizontal.from - b.horizontal.from));
|
|
46
|
+
return groups;
|
|
47
|
+
};
|
|
48
|
+
/** Widens the onset window by a hair, so that rounding in its bounds cannot leave out what `nearby` accepts. */
|
|
49
|
+
const WINDOW_SLACK = 1e-9;
|
|
50
|
+
/** The symbols of a kind whose onset lies within the start tolerance of the span. */
|
|
51
|
+
const nearOnsetOf = (kind, span, tolerance) => {
|
|
52
|
+
const lowest = span.from - tolerance.toleranceStart - WINDOW_SLACK;
|
|
53
|
+
const highest = span.from + tolerance.toleranceStart + WINDOW_SLACK;
|
|
54
|
+
const first = partitionPoint(kind, candidate => candidate.horizontal.from < lowest);
|
|
55
|
+
const end = partitionPoint(kind, candidate => candidate.horizontal.from <= highest);
|
|
56
|
+
return kind.slice(first, end);
|
|
57
|
+
};
|
|
58
|
+
/** Each of the own symbols with every inherited symbol it collates with, both in the order given. */
|
|
59
|
+
export const collationsOf = (own, inherited, locate, tolerance = defaultCollationTolerance) => {
|
|
60
|
+
const kinds = byKindInOrderOfOnset(placed(inherited, locate));
|
|
61
|
+
return placed(own, locate).flatMap(({ symbol, horizontal }) => nearOnsetOf(kinds.get(kindOf(symbol)) ?? [], horizontal, tolerance)
|
|
62
|
+
.filter(candidate => nearby(horizontal, candidate.horizontal, tolerance))
|
|
63
|
+
.sort((a, b) => a.index - b.index)
|
|
64
|
+
.map(({ symbol: counterpart }) => ({ symbol, counterpart })));
|
|
22
65
|
};
|
|
23
|
-
/** Each of the own symbols with every inherited symbol it collates with. */
|
|
24
|
-
export const collationsOf = (own, inherited, locate, tolerance = defaultCollationTolerance) => own.flatMap(symbol => inherited
|
|
25
|
-
.filter(candidate => isCollatable(symbol, candidate, locate, tolerance))
|
|
26
|
-
.map(counterpart => ({ symbol, counterpart })));
|
package/lib/EditionView.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { HorizontalSpan, VerticalSpan, AnyFeature } from "./Feature";
|
|
|
3
3
|
import { AnySymbol, Expression, Note } from "./Symbol";
|
|
4
4
|
import { Version } from "./Version";
|
|
5
5
|
import { NegotiatedEvent } from "./ReproducingSystem";
|
|
6
|
+
import { Millimeters } from "./Quantity";
|
|
6
7
|
export type Path = (string | number)[];
|
|
7
8
|
export declare const getAt: <T>(path: Path, obj: unknown) => T | undefined;
|
|
8
9
|
export declare class EditionView {
|
|
@@ -27,12 +28,22 @@ export declare class EditionView {
|
|
|
27
28
|
getPath(anyId: string): Path | undefined;
|
|
28
29
|
linksTo(anyId: string): Path[];
|
|
29
30
|
travelUp(versionId: string, callback: (version: Readonly<Version>) => void): void;
|
|
31
|
+
/** The version and its ancestors, from the version up to the root. */
|
|
32
|
+
private lineageOf;
|
|
30
33
|
carriersOf(symbol: AnySymbol): Readonly<AnyFeature>[];
|
|
31
34
|
predecessorOf(versionId: string): Readonly<Version> | undefined;
|
|
32
35
|
dimensionOf(symbol: AnySymbol): Readonly<{
|
|
33
36
|
horizontal: HorizontalSpan;
|
|
34
37
|
vertical: VerticalSpan;
|
|
35
38
|
}> | undefined;
|
|
39
|
+
/** Where the symbol begins, as the mean onset of its carriers, or nothing for a symbol without a place. */
|
|
40
|
+
onsetOf(symbol: AnySymbol): Millimeters | undefined;
|
|
41
|
+
/** The symbols by onset, those without a place first; symbols at one place keep their order. */
|
|
42
|
+
private inOrderOfPlace;
|
|
43
|
+
/**
|
|
44
|
+
* The symbols the version shows: what it and its ancestors insert,
|
|
45
|
+
* each version's deletions striking what it or its ancestors inserted.
|
|
46
|
+
*/
|
|
36
47
|
snapshot(versionId: string): readonly Readonly<AnySymbol>[];
|
|
37
48
|
/**
|
|
38
49
|
* Assigns a generation (depth) to every node.
|
package/lib/EditionView.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { deletedBy, insertedBy } from "./Version";
|
|
1
2
|
import { idOf, idsOf } from "./Assumption";
|
|
2
3
|
import { mean } from "./Quantity";
|
|
3
4
|
export const getAt = (path, obj) => {
|
|
@@ -9,11 +10,17 @@ export const getAt = (path, obj) => {
|
|
|
9
10
|
}
|
|
10
11
|
return node;
|
|
11
12
|
};
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
/** Keys under which an object names others by id, singly or in a list. */
|
|
14
|
+
const referenceKeys = ['delete', 'comprehends', 'motivation'];
|
|
15
|
+
const isObject = (v) => v !== null && typeof v === "object";
|
|
16
|
+
/** An object that names another by its id and says nothing else, save perhaps a belief about the reference. */
|
|
17
|
+
const isReferenceOnly = (keys) => keys.every(key => key === 'id' || key === '@annotation');
|
|
18
|
+
const pathOf = (trail) => {
|
|
19
|
+
const path = [];
|
|
20
|
+
for (let link = trail; link !== null; link = link.up)
|
|
21
|
+
path.push(link.key);
|
|
22
|
+
return path.reverse();
|
|
23
|
+
};
|
|
17
24
|
export class EditionView {
|
|
18
25
|
constructor(edition) {
|
|
19
26
|
Object.defineProperty(this, "edition", {
|
|
@@ -58,63 +65,48 @@ export class EditionView {
|
|
|
58
65
|
}
|
|
59
66
|
indexObjects() {
|
|
60
67
|
const visited = new WeakSet();
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
if (
|
|
68
|
+
const link = (id, trail) => {
|
|
69
|
+
const trails = this.links.get(id);
|
|
70
|
+
if (trails)
|
|
71
|
+
trails.push(trail);
|
|
72
|
+
else
|
|
73
|
+
this.links.set(id, [trail]);
|
|
74
|
+
};
|
|
75
|
+
const index = (record, keys, trail) => {
|
|
76
|
+
if (typeof record.id !== "string")
|
|
64
77
|
return;
|
|
65
|
-
if (
|
|
78
|
+
if (isReferenceOnly(keys))
|
|
79
|
+
link(record.id, { key: 'id', up: trail });
|
|
80
|
+
else if (!this.byId.has(record.id)) {
|
|
81
|
+
this.byId.set(record.id, record);
|
|
82
|
+
this.paths.set(record.id, trail);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const linkReferences = (record, trail) => referenceKeys.forEach(key => {
|
|
86
|
+
const ref = record[key];
|
|
87
|
+
if (typeof ref === "string")
|
|
88
|
+
link(ref, { key, up: trail });
|
|
89
|
+
else if (Array.isArray(ref))
|
|
90
|
+
ref.forEach((r, i) => {
|
|
91
|
+
if (typeof r === "string")
|
|
92
|
+
link(r, { key: i, up: { key, up: trail } });
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
const traverse = (node, trail) => {
|
|
96
|
+
if (!isObject(node) || visited.has(node))
|
|
66
97
|
return;
|
|
67
98
|
visited.add(node);
|
|
68
|
-
const anyNode = node;
|
|
69
|
-
if (typeof anyNode.id === "string") {
|
|
70
|
-
if (Object.keys(anyNode).filter(k => k !== '@annotation').length === 1) {
|
|
71
|
-
// this is a reference-only object, store link
|
|
72
|
-
if (!this.links.has(anyNode.id)) {
|
|
73
|
-
this.links.set(anyNode.id, new Set());
|
|
74
|
-
}
|
|
75
|
-
this.links.get(anyNode.id).add([...path, 'id']);
|
|
76
|
-
}
|
|
77
|
-
else {
|
|
78
|
-
if (!this.byId.has(anyNode.id)) {
|
|
79
|
-
this.byId.set(anyNode.id, node);
|
|
80
|
-
this.paths.set(anyNode.id, [...path]);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
Object
|
|
85
|
-
.keys(anyNode)
|
|
86
|
-
.filter(k => referenceKeys.has(k))
|
|
87
|
-
.forEach(key => {
|
|
88
|
-
const ref = anyNode[key];
|
|
89
|
-
if (typeof ref === "string") {
|
|
90
|
-
if (!this.links.has(ref)) {
|
|
91
|
-
this.links.set(ref, new Set());
|
|
92
|
-
}
|
|
93
|
-
this.links.get(ref).add([...path, key]);
|
|
94
|
-
}
|
|
95
|
-
else if (Array.isArray(ref)) {
|
|
96
|
-
for (const r of ref) {
|
|
97
|
-
if (typeof r === "string") {
|
|
98
|
-
if (!this.links.has(anyNode.id)) {
|
|
99
|
-
this.links.set(anyNode.id, new Set());
|
|
100
|
-
}
|
|
101
|
-
this.links.get(anyNode.id).add([...path, key]);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
});
|
|
106
99
|
if (Array.isArray(node)) {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
else {
|
|
112
|
-
for (const key of Object.keys(node)) {
|
|
113
|
-
traverse(anyNode[key], [...path, key]);
|
|
114
|
-
}
|
|
100
|
+
node.forEach((item, i) => traverse(item, { key: i, up: trail }));
|
|
101
|
+
return;
|
|
115
102
|
}
|
|
103
|
+
const record = node;
|
|
104
|
+
const keys = Object.keys(record);
|
|
105
|
+
index(record, keys, trail);
|
|
106
|
+
linkReferences(record, trail);
|
|
107
|
+
keys.forEach(key => traverse(record[key], { key, up: trail }));
|
|
116
108
|
};
|
|
117
|
-
traverse(this.edition,
|
|
109
|
+
traverse(this.edition, null);
|
|
118
110
|
}
|
|
119
111
|
get(anyId) {
|
|
120
112
|
return this.byId.get(anyId);
|
|
@@ -123,10 +115,11 @@ export class EditionView {
|
|
|
123
115
|
return anyIds.map(id => this.get(id)).filter((v) => !!v);
|
|
124
116
|
}
|
|
125
117
|
getPath(anyId) {
|
|
126
|
-
|
|
118
|
+
const trail = this.paths.get(anyId);
|
|
119
|
+
return trail === undefined ? undefined : pathOf(trail);
|
|
127
120
|
}
|
|
128
121
|
linksTo(anyId) {
|
|
129
|
-
return
|
|
122
|
+
return (this.links.get(anyId) ?? []).map(pathOf);
|
|
130
123
|
}
|
|
131
124
|
travelUp(versionId, callback) {
|
|
132
125
|
const v = this.get(versionId);
|
|
@@ -137,6 +130,12 @@ export class EditionView {
|
|
|
137
130
|
this.travelUp(idOf(v.basedOn), callback);
|
|
138
131
|
}
|
|
139
132
|
}
|
|
133
|
+
/** The version and its ancestors, from the version up to the root. */
|
|
134
|
+
lineageOf(versionId) {
|
|
135
|
+
const lineage = [];
|
|
136
|
+
this.travelUp(versionId, version => lineage.push(version));
|
|
137
|
+
return lineage;
|
|
138
|
+
}
|
|
140
139
|
carriersOf(symbol) {
|
|
141
140
|
return this.getAll(idsOf(symbol.carriers));
|
|
142
141
|
}
|
|
@@ -165,32 +164,29 @@ export class EditionView {
|
|
|
165
164
|
}
|
|
166
165
|
};
|
|
167
166
|
}
|
|
167
|
+
/** Where the symbol begins, as the mean onset of its carriers, or nothing for a symbol without a place. */
|
|
168
|
+
onsetOf(symbol) {
|
|
169
|
+
const carriers = this.carriersOf(symbol);
|
|
170
|
+
return carriers.length > 0 ? mean(carriers.map(carrier => carrier.horizontal.from)) : undefined;
|
|
171
|
+
}
|
|
172
|
+
/** The symbols by onset, those without a place first; symbols at one place keep their order. */
|
|
173
|
+
inOrderOfPlace(symbols) {
|
|
174
|
+
return symbols
|
|
175
|
+
.map(symbol => ({ symbol, at: this.onsetOf(symbol) || 0 }))
|
|
176
|
+
.sort((a, b) => a.at - b.at)
|
|
177
|
+
.map(({ symbol }) => symbol);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* The symbols the version shows: what it and its ancestors insert,
|
|
181
|
+
* each version's deletions striking what it or its ancestors inserted.
|
|
182
|
+
*/
|
|
168
183
|
snapshot(versionId) {
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
// as we travel further up, remove symbols that are
|
|
174
|
-
// deleted in the versions further down
|
|
175
|
-
const deleted = [];
|
|
176
|
-
for (const toRemove of toDelete) {
|
|
177
|
-
const index = snapshot.findIndex(s => s.id === toRemove);
|
|
178
|
-
if (index !== -1) {
|
|
179
|
-
snapshot.splice(index, 1);
|
|
180
|
-
deleted.push(toRemove);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
for (const del of deleted) {
|
|
184
|
-
toDelete.splice(toDelete.indexOf(del), 1);
|
|
185
|
-
}
|
|
186
|
-
// collect symbols that are deleted in the current version
|
|
187
|
-
toDelete.push(...s.edits.flatMap(edit => edit.delete || []));
|
|
188
|
-
});
|
|
189
|
-
return snapshot.sort((a, b) => {
|
|
190
|
-
const aDimension = this.dimensionOf(a);
|
|
191
|
-
const bDimension = this.dimensionOf(b);
|
|
192
|
-
return (aDimension?.horizontal.from || 0) - (bDimension?.horizontal.from || 0);
|
|
184
|
+
const deleted = new Set();
|
|
185
|
+
const symbols = this.lineageOf(versionId).flatMap(version => {
|
|
186
|
+
deletedBy(version).forEach(id => deleted.add(id));
|
|
187
|
+
return insertedBy(version).filter(symbol => !deleted.has(symbol.id));
|
|
193
188
|
});
|
|
189
|
+
return this.inOrderOfPlace(symbols);
|
|
194
190
|
}
|
|
195
191
|
/**
|
|
196
192
|
* Assigns a generation (depth) to every node.
|
package/lib/Emulation.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { MIDIControlEvents } from "midifile-ts";
|
|
2
2
|
import { idOf } from "./Assumption";
|
|
3
|
-
import { pairsAmong, placementsOf } from "./Symbol";
|
|
3
|
+
import { isPerforation, pairsAmong, placementsOf } from "./Symbol";
|
|
4
4
|
import { add, mean, mm, seconds, subtract } from "./Quantity";
|
|
5
5
|
/** The punch diameter the edition's copies report, where any of them does. */
|
|
6
6
|
const punchDiameterOf = (view) => {
|
|
@@ -83,6 +83,8 @@ const displacementsOf = (events, offsetsBetween, gap) => {
|
|
|
83
83
|
});
|
|
84
84
|
return displacements;
|
|
85
85
|
};
|
|
86
|
+
/** The earliest of the times, or the beginning of the roll where there are none. */
|
|
87
|
+
const earliestOf = (times) => times.length === 0 ? seconds(0) : times.reduce((soonest, at) => at < soonest ? at : soonest);
|
|
86
88
|
/**
|
|
87
89
|
* A version of the edition, performed: the symbols are negotiated into
|
|
88
90
|
* placed events, the reproducing system plays them, and the result goes
|
|
@@ -147,22 +149,14 @@ export class Emulation {
|
|
|
147
149
|
}
|
|
148
150
|
emulateVersion(version, view, { range, skipToFirstNote = false } = {}) {
|
|
149
151
|
this.source = version.id;
|
|
152
|
+
/** A note plays only where its onset falls in the range; expressions play throughout. */
|
|
153
|
+
const inScope = (event) => !range || event.type !== 'note' || (event.horizontal.from > range[0] && event.horizontal.from < range[1]);
|
|
150
154
|
this.negotiatedEvents =
|
|
151
155
|
view.snapshot(version.id)
|
|
152
|
-
.filter(
|
|
153
|
-
.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (!dimensions)
|
|
157
|
-
return true; // in case of doubt, include the note
|
|
158
|
-
// check if the note onset is within the specified range
|
|
159
|
-
const onset = dimensions.horizontal.from;
|
|
160
|
-
return onset > range[0] && onset < range[1];
|
|
161
|
-
}
|
|
162
|
-
return true;
|
|
163
|
-
})
|
|
164
|
-
.map((e) => view.simplifySymbol(e))
|
|
165
|
-
.filter(s => s !== null);
|
|
156
|
+
.filter(isPerforation)
|
|
157
|
+
.map(symbol => view.simplifySymbol(symbol))
|
|
158
|
+
.filter(event => event !== null)
|
|
159
|
+
.filter(inScope);
|
|
166
160
|
if (this.negotiatedEvents.length === 0) {
|
|
167
161
|
this.midiEvents = [];
|
|
168
162
|
this.curves = [];
|
|
@@ -172,7 +166,7 @@ export class Emulation {
|
|
|
172
166
|
const performance = this.system.perform(this.negotiatedEvents, this.options, propertiesOf(view));
|
|
173
167
|
this.curves = performance.curves;
|
|
174
168
|
const onsets = performance.events.filter(event => event.type === 'noteOn').map(event => event.at);
|
|
175
|
-
const origin =
|
|
169
|
+
const origin = skipToFirstNote ? earliestOf(onsets) : seconds(0);
|
|
176
170
|
this.midiEvents = performance.events
|
|
177
171
|
.map(event => ({ ...event, at: subtract(event.at, origin) }))
|
|
178
172
|
.filter(event => event.at >= 0)
|
package/lib/Version.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Edit } from "./Edit";
|
|
2
2
|
import { ReferenceAssumption } from "./Assumption";
|
|
3
|
+
import { AnySymbol } from "./Symbol";
|
|
3
4
|
import { WithId, WithNote, WithType } from "./utils";
|
|
4
5
|
export declare const versionTypes: readonly [
|
|
5
6
|
/**
|
|
@@ -56,3 +57,7 @@ export interface Version extends WithId, WithType<'Version'> {
|
|
|
56
57
|
*/
|
|
57
58
|
motivations: Motivation[];
|
|
58
59
|
}
|
|
60
|
+
/** The symbols the version's edits insert. */
|
|
61
|
+
export declare const insertedBy: (version: Readonly<Version>) => AnySymbol[];
|
|
62
|
+
/** The ids of the symbols the version's edits delete. */
|
|
63
|
+
export declare const deletedBy: (version: Readonly<Version>) => string[];
|
package/lib/Version.js
CHANGED
|
@@ -9,3 +9,7 @@ export const versionTypes = [
|
|
|
9
9
|
*/
|
|
10
10
|
'unicum'
|
|
11
11
|
];
|
|
12
|
+
/** The symbols the version's edits insert. */
|
|
13
|
+
export const insertedBy = (version) => version.edits.flatMap(edit => edit.insert ?? []);
|
|
14
|
+
/** The ids of the symbols the version's edits delete. */
|
|
15
|
+
export const deletedBy = (version) => version.edits.flatMap(edit => edit.delete ?? []);
|
package/lib/constraints.js
CHANGED
|
@@ -25,9 +25,11 @@ const problemsIn = (version, perforations) => {
|
|
|
25
25
|
.filter(p => p.pairedWith && idOf(p.pairedWith) === p.id)
|
|
26
26
|
.map(p => report(p.id, 'paired-with-itself'));
|
|
27
27
|
const pairs = pairsAmong(perforations);
|
|
28
|
-
const
|
|
28
|
+
const pairsPerId = pairs
|
|
29
|
+
.flatMap(([one, other]) => one === other ? [one] : [one, other])
|
|
30
|
+
.reduce((counts, p) => counts.set(p.id, (counts.get(p.id) ?? 0) + 1), new Map());
|
|
29
31
|
const inSeveralPairs = perforations
|
|
30
|
-
.filter(p =>
|
|
32
|
+
.filter(p => (pairsPerId.get(p.id) ?? 0) > 1)
|
|
31
33
|
.map(p => report(p.id, 'in-several-pairs'));
|
|
32
34
|
const placedOnBothSides = pairs
|
|
33
35
|
.filter(([one, other]) => placementsOf(one).length > 0 && placementsOf(other).length > 0)
|
package/lib/editionOps.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { current, isDraft } from "immer";
|
|
1
2
|
import { v4 } from "uuid";
|
|
2
3
|
import { getAt } from "./EditionView";
|
|
3
4
|
import { isPerforation, placementRelations } from "./Symbol";
|
|
4
5
|
import { collationsOf, defaultCollationTolerance } from "./Collation";
|
|
6
|
+
import { insertedBy } from "./Version";
|
|
5
7
|
import { asSymbols } from "./RollCopy";
|
|
6
8
|
import { applyShift, applyStretch, revertShift, revertStretch } from "./alignment";
|
|
7
9
|
import { assignReference, idOf } from "./Assumption";
|
|
@@ -17,23 +19,36 @@ const onVersion = (versionId, op) => draft => {
|
|
|
17
19
|
if (version)
|
|
18
20
|
op(version, draft);
|
|
19
21
|
};
|
|
22
|
+
/**
|
|
23
|
+
* The state a draft stands at, as plain data. Reading a draft proxies
|
|
24
|
+
* everything it touches, so what is only read is read from this.
|
|
25
|
+
*/
|
|
26
|
+
const stateOf = (draft) => isDraft(draft) ? current(draft) : draft;
|
|
20
27
|
/** The items that do not match, or the very same array when none does, so that a draft stays untouched. */
|
|
21
28
|
const without = (items, matches) => items.some(matches) ? items.filter(item => !matches(item)) : items;
|
|
29
|
+
/** The items each changed, or the very same array when the change left every one as it was. */
|
|
30
|
+
const mapped = (items, change) => {
|
|
31
|
+
const changed = items.map(change);
|
|
32
|
+
return changed.every((item, i) => item === items[i]) ? items : changed;
|
|
33
|
+
};
|
|
22
34
|
const insertion = (symbol) => ({ type: 'edit', id: v4(), insert: [symbol] });
|
|
23
35
|
const deletion = (symbolId) => ({ type: 'edit', id: v4(), delete: [symbolId] });
|
|
24
36
|
const isEmpty = (edit) => !edit.insert?.length && !edit.delete?.length;
|
|
25
|
-
const insertedIn = (versions) => versions.flatMap(
|
|
26
|
-
/**
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
37
|
+
const insertedIn = (versions) => versions.flatMap(insertedBy);
|
|
38
|
+
/** The edits with the change applied, less those it emptied; the very same array where it changed none. */
|
|
39
|
+
const edited = (edits, change) => {
|
|
40
|
+
const changed = mapped(edits, change);
|
|
41
|
+
return changed === edits ? edits : changed.filter((edit, i) => edit === edits[i] || !isEmpty(edit));
|
|
42
|
+
};
|
|
43
|
+
/** The edit without the symbols among its insertions, or the very same edit where it inserts none of them. */
|
|
44
|
+
const droppingInsertions = (symbolIds) => (edit) => {
|
|
45
|
+
const insert = edit.insert && without(edit.insert, symbol => symbolIds.has(symbol.id));
|
|
46
|
+
return insert === edit.insert ? edit : { ...edit, insert };
|
|
47
|
+
};
|
|
48
|
+
/** Takes the symbols out of the version's own insertions, and the edits that had nothing else. */
|
|
49
|
+
const dropInsertions = (version, symbolIds) => {
|
|
50
|
+
version.edits = edited(stateOf(version).edits, droppingInsertions(symbolIds));
|
|
31
51
|
};
|
|
32
|
-
/** Takes the symbols out of the version's own insertions. */
|
|
33
|
-
const dropInsertions = (version, symbolIds) => editing(version, edit => {
|
|
34
|
-
if (edit.insert)
|
|
35
|
-
edit.insert = without(edit.insert, symbol => symbolIds.has(symbol.id));
|
|
36
|
-
});
|
|
37
52
|
/**
|
|
38
53
|
* Puts the copy into the edition with a version of its own, which
|
|
39
54
|
* inserts every symbol the tracker bar reads on the copy.
|
|
@@ -72,25 +87,34 @@ export const symbolsCarriedOnlyBy = (edition, copyId) => {
|
|
|
72
87
|
return copy ? insertedIn(edition.versions).filter(carriedOnlyOn(featureIdsOf(copy))) : [];
|
|
73
88
|
};
|
|
74
89
|
const references = [...placementRelations, 'pairedWith'];
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
90
|
+
/** The perforation without its references to the dropped symbols, or the very same one where it makes none. */
|
|
91
|
+
const forgettingReferences = (perforation, dropped) => {
|
|
92
|
+
const stale = references.filter(relation => {
|
|
93
|
+
const reference = perforation[relation];
|
|
94
|
+
return reference !== undefined && dropped.has(idOf(reference));
|
|
95
|
+
});
|
|
96
|
+
if (stale.length === 0)
|
|
97
|
+
return perforation;
|
|
98
|
+
const kept = { ...perforation };
|
|
99
|
+
stale.forEach(relation => { delete kept[relation]; });
|
|
100
|
+
return kept;
|
|
85
101
|
};
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
102
|
+
/** The symbol without the features among its carriers, and without references to what went with them. */
|
|
103
|
+
const forgettingCarriers = (features, dropped) => (symbol) => {
|
|
104
|
+
const carriers = without(symbol.carriers, carrier => features.has(idOf(carrier)));
|
|
105
|
+
const relieved = carriers === symbol.carriers ? symbol : { ...symbol, carriers };
|
|
106
|
+
return isPerforation(relieved) ? forgettingReferences(relieved, dropped) : relieved;
|
|
107
|
+
};
|
|
108
|
+
/** The edit without the dropped symbols and the features, or the very same edit where it had none of them. */
|
|
109
|
+
const forgettingFeatures = (features, dropped) => {
|
|
110
|
+
const forgetCarriers = forgettingCarriers(features, dropped);
|
|
111
|
+
return (edit) => {
|
|
112
|
+
const insert = edit.insert && mapped(without(edit.insert, symbol => dropped.has(symbol.id)), forgetCarriers);
|
|
113
|
+
const deleted = edit.delete && without(edit.delete, id => dropped.has(id));
|
|
114
|
+
if (insert === edit.insert && deleted === edit.delete)
|
|
115
|
+
return edit;
|
|
116
|
+
return { ...edit, ...(insert && { insert }), ...(deleted && { delete: deleted }) };
|
|
117
|
+
};
|
|
94
118
|
};
|
|
95
119
|
/**
|
|
96
120
|
* Strikes the features from the versions: their carriers go, a symbol
|
|
@@ -98,8 +122,12 @@ const forgetFeaturesInEdit = (edit, features, dropped) => {
|
|
|
98
122
|
* reference the versions made to such a symbol.
|
|
99
123
|
*/
|
|
100
124
|
const forgetFeatures = (draft, features) => {
|
|
101
|
-
const
|
|
102
|
-
|
|
125
|
+
const versions = stateOf(draft.versions);
|
|
126
|
+
const dropped = new Set(insertedIn(versions).filter(carriedOnlyOn(features)).map(symbol => symbol.id));
|
|
127
|
+
const forget = forgettingFeatures(features, dropped);
|
|
128
|
+
draft.versions.forEach((version, i) => {
|
|
129
|
+
version.edits = edited(versions[i].edits, forget);
|
|
130
|
+
});
|
|
103
131
|
};
|
|
104
132
|
/** Takes the features off the copy, and out of the versions with what only they carried. */
|
|
105
133
|
export const removeFeatures = (copyId, featureIds) => onCopy(copyId, (copy, draft) => {
|
package/lib/importJsonLd.d.ts
CHANGED
package/lib/importJsonLd.js
CHANGED
|
@@ -10,49 +10,29 @@ export const importDate = (str) => {
|
|
|
10
10
|
}
|
|
11
11
|
return new Date(y, m - 1, d);
|
|
12
12
|
};
|
|
13
|
+
/** A value as the edition holds it: a date read, an entity converted, anything else as it stands. */
|
|
14
|
+
const fromJsonLdValue = (value) => {
|
|
15
|
+
if (typeof value === 'string')
|
|
16
|
+
return isDate(value) ? importDate(value) : value;
|
|
17
|
+
if (Array.isArray(value))
|
|
18
|
+
return value.map(fromJsonLdValue);
|
|
19
|
+
if (value !== null && typeof value === 'object')
|
|
20
|
+
return fromJsonLdEntity(value);
|
|
21
|
+
return value;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* An entity with its keywords read as plain keys. The input is left as
|
|
25
|
+
* it is. The `@type` of a value object names the datatype of the value,
|
|
26
|
+
* not a class, and is dropped.
|
|
27
|
+
*/
|
|
13
28
|
const fromJsonLdEntity = (json) => {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
else if ('@type' in json) {
|
|
23
|
-
result['type'] = json['@type'];
|
|
24
|
-
delete result['@type'];
|
|
25
|
-
}
|
|
26
|
-
for (const [key, value] of Object.entries(json)) {
|
|
27
|
-
if (key === '@id') {
|
|
28
|
-
// console.log("deleting @id", value);
|
|
29
|
-
result['id'] = value;
|
|
30
|
-
delete result['@id'];
|
|
31
|
-
}
|
|
32
|
-
else if (typeof value === 'string' && isDate(value)) {
|
|
33
|
-
result[key] = importDate(value);
|
|
34
|
-
}
|
|
35
|
-
else if (Array.isArray(value)) {
|
|
36
|
-
result[key] = value.map(v => {
|
|
37
|
-
if (typeof v === 'string') {
|
|
38
|
-
if (isDate(v)) {
|
|
39
|
-
return importDate(v);
|
|
40
|
-
}
|
|
41
|
-
return v;
|
|
42
|
-
}
|
|
43
|
-
else {
|
|
44
|
-
return fromJsonLdEntity(v);
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
else if (typeof value === 'object') {
|
|
49
|
-
result[key] = fromJsonLdEntity(value);
|
|
50
|
-
}
|
|
51
|
-
else {
|
|
52
|
-
result[key] = value;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
return result;
|
|
29
|
+
const { '@type': type, '@id': id, ...rest } = json;
|
|
30
|
+
const entity = Object.fromEntries(Object.entries(rest).map(([key, value]) => [key, fromJsonLdValue(value)]));
|
|
31
|
+
if (type !== undefined && !('@value' in json))
|
|
32
|
+
entity.type = type;
|
|
33
|
+
if (id !== undefined)
|
|
34
|
+
entity.id = id;
|
|
35
|
+
return entity;
|
|
56
36
|
};
|
|
57
37
|
// The export prefixes copy identifiers with `copy/`; this is its inverse.
|
|
58
38
|
const withPlainCopyIds = (json) => ({
|
package/lib/index.d.ts
CHANGED
|
@@ -24,4 +24,4 @@ export * from './context';
|
|
|
24
24
|
export * from './asJsonLd';
|
|
25
25
|
export * from './importJsonLd';
|
|
26
26
|
export { readFromStanfordAton, type StanfordAtonOptions } from './readers/stanfordAton';
|
|
27
|
-
export {
|
|
27
|
+
export { readFromSpencerBar, licenseeOnT100, SPENCER_ROWS_PER_INCH, type SpencerBarOptions } from './readers/spencerBar';
|
package/lib/index.js
CHANGED
|
@@ -24,4 +24,4 @@ export * from './context';
|
|
|
24
24
|
export * from './asJsonLd';
|
|
25
25
|
export * from './importJsonLd';
|
|
26
26
|
export { readFromStanfordAton } from './readers/stanfordAton';
|
|
27
|
-
export {
|
|
27
|
+
export { readFromSpencerBar, licenseeOnT100, SPENCER_ROWS_PER_INCH } from './readers/spencerBar';
|
package/lib/migrate.js
CHANGED
|
@@ -12,7 +12,9 @@ const renamedKeys = {
|
|
|
12
12
|
};
|
|
13
13
|
const referenceKeys = ['alignedWith', 'pairedWith', 'basedOn'];
|
|
14
14
|
const named = (name) => ({ name, sameAs: [] });
|
|
15
|
-
const withRenamedKeys = (node) => Object.
|
|
15
|
+
const withRenamedKeys = (node) => Object.keys(node).some(key => Object.hasOwn(renamedKeys, key))
|
|
16
|
+
? Object.fromEntries(Object.entries(node).map(([key, value]) => [renamedKeys[key] ?? key, value]))
|
|
17
|
+
: node;
|
|
16
18
|
const withTypology = (node) => {
|
|
17
19
|
if (versionTypeValues.has(node['@type'])) {
|
|
18
20
|
return { ...node, '@type': 'Version', versionType: node['@type'] };
|
|
@@ -52,12 +54,22 @@ const withProductionNodes = (node) => {
|
|
|
52
54
|
};
|
|
53
55
|
const migrateNode = (node) => [withRenamedKeys, withTypology, withReferences, withKeeper, withProductionNodes]
|
|
54
56
|
.reduce((result, step) => step(result), node);
|
|
57
|
+
/** The items each walked, or the very same list where the walk changed none. */
|
|
58
|
+
const walked = (items) => {
|
|
59
|
+
const result = items.map(walk);
|
|
60
|
+
return result.every((item, i) => item === items[i]) ? items : result;
|
|
61
|
+
};
|
|
62
|
+
/** The node with each child walked, or the very same node where the walk changed none. */
|
|
63
|
+
const withWalkedChildren = (node) => {
|
|
64
|
+
const entries = Object.entries(node);
|
|
65
|
+
const result = entries.map(([key, child]) => [key, walk(child)]);
|
|
66
|
+
return result.every(([, child], i) => child === entries[i][1]) ? node : Object.fromEntries(result);
|
|
67
|
+
};
|
|
55
68
|
const walk = (value) => {
|
|
56
69
|
if (Array.isArray(value))
|
|
57
|
-
return value
|
|
58
|
-
if (value && typeof value === 'object')
|
|
59
|
-
return
|
|
60
|
-
}
|
|
70
|
+
return walked(value);
|
|
71
|
+
if (value && typeof value === 'object')
|
|
72
|
+
return withWalkedChildren(migrateNode(value));
|
|
61
73
|
return value;
|
|
62
74
|
};
|
|
63
75
|
/**
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { RollCopy } from "../RollCopy";
|
|
2
|
+
import { Track } from "../Quantity";
|
|
3
|
+
/**
|
|
4
|
+
* Rows of the image on an inch of paper. The player reads eight rows a
|
|
5
|
+
* second per unit of roll tempo (tempo 80 comes with a sample rate of
|
|
6
|
+
* 640 Hz), and the tempo counts tenths of a foot per minute, which puts
|
|
7
|
+
* 400 rows on an inch. This is the file's own calibration; whether the
|
|
8
|
+
* scanner kept it is for an alignment with other copies to tell.
|
|
9
|
+
*/
|
|
10
|
+
export declare const SPENCER_ROWS_PER_INCH = 400;
|
|
11
|
+
export declare const licenseeOnT100: (position: number) => Track;
|
|
12
|
+
export interface SpencerBarOptions {
|
|
13
|
+
/** Rows of the image on an inch of paper. */
|
|
14
|
+
rowsPerInch?: number;
|
|
15
|
+
/** Puts a position the file names onto the edition's bar. */
|
|
16
|
+
trackOf?: (position: number) => Track;
|
|
17
|
+
}
|
|
18
|
+
export declare function readFromSpencerBar(buffer: ArrayBuffer, { rowsPerInch, trackOf }?: SpencerBarOptions): RollCopy;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { v4 } from "uuid";
|
|
2
|
+
import { welteT100 } from "../systems/welteT100/bar";
|
|
3
|
+
import { inMillimeters, px, track } from "../Quantity";
|
|
4
|
+
/**
|
|
5
|
+
* Spencer Chase's e-roll file (`.bar`, "eRoll Tracker Bar Image") holds
|
|
6
|
+
* a roll as a list of events: a distance in rows of the scanned image,
|
|
7
|
+
* then the tracker bar position whose hole begins or ends on that row.
|
|
8
|
+
*
|
|
9
|
+
* Layout, as read off W225E.bar: four bytes (00 01 00 00), the tag F4
|
|
10
|
+
* and a null-terminated text ("/oldtrackname:..."), then the events,
|
|
11
|
+
* each an unsigned LEB128 distance and one position byte. A position
|
|
12
|
+
* byte of FF closes the list. A hole ends when its position turns up
|
|
13
|
+
* a second time.
|
|
14
|
+
*/
|
|
15
|
+
const TEXT_AT = 4;
|
|
16
|
+
const TEXT_TAG = 0xF4;
|
|
17
|
+
const END_OF_EVENTS = 0xFF;
|
|
18
|
+
/**
|
|
19
|
+
* Rows of the image on an inch of paper. The player reads eight rows a
|
|
20
|
+
* second per unit of roll tempo (tempo 80 comes with a sample rate of
|
|
21
|
+
* 640 Hz), and the tempo counts tenths of a foot per minute, which puts
|
|
22
|
+
* 400 rows on an inch. This is the file's own calibration; whether the
|
|
23
|
+
* scanner kept it is for an alignment with other copies to tell.
|
|
24
|
+
*/
|
|
25
|
+
export const SPENCER_ROWS_PER_INCH = 400;
|
|
26
|
+
/**
|
|
27
|
+
* The file numbers positions as the 98-hole Welte Licensee bar does.
|
|
28
|
+
* Its bass controls are the T-100's tracks 1 to 8, it has no motor
|
|
29
|
+
* tracks, and its note block follows the controls at once, so from
|
|
30
|
+
* there on every position lies two tracks lower than on the T-100 bar.
|
|
31
|
+
* Checked valve by valve on roll 225 against the Stanford copies.
|
|
32
|
+
*/
|
|
33
|
+
const LICENSEE_NOTES_FROM = 9;
|
|
34
|
+
const T100_NOTES_FROM = welteT100.areas.find(area => area.role === 'note').from;
|
|
35
|
+
export const licenseeOnT100 = (position) => track(position < LICENSEE_NOTES_FROM ? position : position + T100_NOTES_FROM - LICENSEE_NOTES_FROM);
|
|
36
|
+
const byteAt = (bytes, at) => {
|
|
37
|
+
const byte = bytes[at];
|
|
38
|
+
if (byte === undefined)
|
|
39
|
+
throw new Error(`Spencer .bar file ends early at byte ${at}`);
|
|
40
|
+
return byte;
|
|
41
|
+
};
|
|
42
|
+
const leb128 = (bytes, at) => {
|
|
43
|
+
let value = 0;
|
|
44
|
+
let next = at;
|
|
45
|
+
for (let weight = 1;; weight *= 128) {
|
|
46
|
+
const byte = byteAt(bytes, next++);
|
|
47
|
+
value += (byte & 0x7F) * weight;
|
|
48
|
+
if (!(byte & 0x80))
|
|
49
|
+
return { value, next };
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
const endOfText = (bytes, from) => {
|
|
53
|
+
const end = bytes.indexOf(0, from);
|
|
54
|
+
if (end < 0)
|
|
55
|
+
throw new Error('Spencer .bar file: the text is not terminated');
|
|
56
|
+
return end + 1;
|
|
57
|
+
};
|
|
58
|
+
function* eventsIn(bytes, from) {
|
|
59
|
+
let row = 0;
|
|
60
|
+
let at = from;
|
|
61
|
+
while (true) {
|
|
62
|
+
const distance = leb128(bytes, at);
|
|
63
|
+
const position = byteAt(bytes, distance.next);
|
|
64
|
+
if (position === END_OF_EVENTS)
|
|
65
|
+
return;
|
|
66
|
+
row += distance.value;
|
|
67
|
+
yield { row, position };
|
|
68
|
+
at = distance.next + 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Pairs the events of each position into holes: the first opens one, the next closes it. */
|
|
72
|
+
const holesOf = (events) => {
|
|
73
|
+
const open = new Map();
|
|
74
|
+
const holes = [];
|
|
75
|
+
for (const { row, position } of events) {
|
|
76
|
+
const from = open.get(position);
|
|
77
|
+
if (from === undefined) {
|
|
78
|
+
open.set(position, row);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
holes.push({ position, from, to: row });
|
|
82
|
+
open.delete(position);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (open.size > 0) {
|
|
86
|
+
throw new Error(`Spencer .bar file: holes on positions ${[...open.keys()].join(', ')} never end`);
|
|
87
|
+
}
|
|
88
|
+
return holes.sort((a, b) => a.from - b.from);
|
|
89
|
+
};
|
|
90
|
+
export function readFromSpencerBar(buffer, { rowsPerInch = SPENCER_ROWS_PER_INCH, trackOf = licenseeOnT100 } = {}) {
|
|
91
|
+
const bytes = new Uint8Array(buffer);
|
|
92
|
+
if (byteAt(bytes, TEXT_AT) !== TEXT_TAG) {
|
|
93
|
+
throw new Error('Not a Spencer .bar file: no text after the header');
|
|
94
|
+
}
|
|
95
|
+
const placeOf = (row) => inMillimeters(px(row), rowsPerInch);
|
|
96
|
+
const features = holesOf(eventsIn(bytes, endOfText(bytes, TEXT_AT + 1)))
|
|
97
|
+
.map((hole) => ({
|
|
98
|
+
type: 'Hole',
|
|
99
|
+
id: v4(),
|
|
100
|
+
vertical: {
|
|
101
|
+
from: trackOf(hole.position),
|
|
102
|
+
unit: 'track'
|
|
103
|
+
},
|
|
104
|
+
horizontal: {
|
|
105
|
+
unit: 'mm',
|
|
106
|
+
from: placeOf(hole.from),
|
|
107
|
+
to: placeOf(hole.to)
|
|
108
|
+
}
|
|
109
|
+
}));
|
|
110
|
+
return {
|
|
111
|
+
type: 'RollCopy',
|
|
112
|
+
id: v4(),
|
|
113
|
+
ops: [],
|
|
114
|
+
conditions: [],
|
|
115
|
+
keeper: { name: '', sameAs: [] },
|
|
116
|
+
measurements: {},
|
|
117
|
+
modifications: [],
|
|
118
|
+
features
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -50,6 +50,10 @@ const gridOffsetOf = (holes, separation, stated) => {
|
|
|
50
50
|
return readPx(stated);
|
|
51
51
|
return px(median(holes.map(hole => readPx(hole.CENTROID_COL) - +hole.TRACKER_HOLE * separation)));
|
|
52
52
|
};
|
|
53
|
+
const chainsAmong = (holes) => holes
|
|
54
|
+
.filter(hole => hole.NOTE_ATTACK && hole.OFF_TIME)
|
|
55
|
+
.map(hole => ({ hole, attack: readPx(hole.NOTE_ATTACK), release: readPx(hole.OFF_TIME) }))
|
|
56
|
+
.sort((a, b) => a.attack - b.attack);
|
|
53
57
|
const punchDiameterOf = (holes, dpi) => {
|
|
54
58
|
const circular = holes
|
|
55
59
|
.filter(hole => parseFloat(hole.CIRCULARITY) > 0.95)
|
|
@@ -97,13 +101,9 @@ export function readFromStanfordAton(atonString, { trackShift, bar = welteT100,
|
|
|
97
101
|
shift
|
|
98
102
|
};
|
|
99
103
|
const punchDiameter = punchDiameterOf(holes, dpi);
|
|
100
|
-
const chains = [...holes, ...chainedBadHoles(listOf(json.ROLLINFO.BADHOLES?.HOLE), calibration)]
|
|
101
|
-
.filter(hole => hole.NOTE_ATTACK && hole.OFF_TIME)
|
|
102
|
-
.sort((a, b) => readPx(a.NOTE_ATTACK) - readPx(b.NOTE_ATTACK));
|
|
104
|
+
const chains = chainsAmong([...holes, ...chainedBadHoles(listOf(json.ROLLINFO.BADHOLES?.HOLE), calibration)]);
|
|
103
105
|
const features = chains
|
|
104
|
-
.map((hole) => {
|
|
105
|
-
const attack = readPx(hole.NOTE_ATTACK);
|
|
106
|
-
const release = readPx(hole.OFF_TIME);
|
|
106
|
+
.map(({ hole, attack, release }) => {
|
|
107
107
|
const column = readPx(hole.ORIGIN_COL);
|
|
108
108
|
const columnWidth = readPx(hole.WIDTH_COL);
|
|
109
109
|
return {
|
package/lib/sorted.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The index at which a list partitioned by the predicate turns: it
|
|
3
|
+
* holds for every item before that index and for none from it on.
|
|
4
|
+
* Binary search, so the list is read in logarithmic time.
|
|
5
|
+
*/
|
|
6
|
+
export declare const partitionPoint: <T>(partitioned: readonly T[], holds: (item: T) => boolean) => number;
|
package/lib/sorted.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The index at which a list partitioned by the predicate turns: it
|
|
3
|
+
* holds for every item before that index and for none from it on.
|
|
4
|
+
* Binary search, so the list is read in logarithmic time.
|
|
5
|
+
*/
|
|
6
|
+
export const partitionPoint = (partitioned, holds) => {
|
|
7
|
+
let low = 0;
|
|
8
|
+
let high = partitioned.length;
|
|
9
|
+
while (low < high) {
|
|
10
|
+
const middle = (low + high) >>> 1;
|
|
11
|
+
if (holds(partitioned[middle]))
|
|
12
|
+
low = middle + 1;
|
|
13
|
+
else
|
|
14
|
+
high = middle;
|
|
15
|
+
}
|
|
16
|
+
return low;
|
|
17
|
+
};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { aperturePorts, CONSENSUS, DEFAULT_PUNCH_MM, geometryInMm, Grid, levelChanges, mezzoforteTravel, paperSeconds, pedalBrushing, pedalDefaults, pneumaticModel, PRESETS, ROWS_PER_MM, runPedals, TRACKER_BORE_MM, travelBetweenRails, WELTE_SPOOL, } from "welte-t100-emulator";
|
|
2
2
|
import { welteT100 } from "./bar";
|
|
3
3
|
import { add, inCentimeters, mm, seconds, track } from "../../Quantity";
|
|
4
|
+
import { partitionPoint } from "../../sorted";
|
|
4
5
|
/**
|
|
5
6
|
* The instruments the emulator was fitted as: the consensus over the six
|
|
6
7
|
* rolls with drawn nuance lines, and the setting that drew each of them,
|
|
@@ -113,7 +114,7 @@ const velocityOf = (travel, hook, map) => {
|
|
|
113
114
|
const gridOver = (events, spool) => {
|
|
114
115
|
const last = mm(events.reduce((furthest, event) => Math.max(furthest, event.horizontal.to), 0));
|
|
115
116
|
const length = Math.ceil(rowOf(add(last, RUN_OUT))) + 1;
|
|
116
|
-
const times = Float64Array
|
|
117
|
+
const times = new Float64Array(length).map((_, row) => secondsAt(spool, placeOfRow(row)));
|
|
117
118
|
return new Grid(0, times);
|
|
118
119
|
};
|
|
119
120
|
const nuanceCurves = (grid, ports, samples, options) => {
|
|
@@ -127,7 +128,7 @@ const nuanceCurves = (grid, ports, samples, options) => {
|
|
|
127
128
|
name: half,
|
|
128
129
|
kind: 'dynamics',
|
|
129
130
|
travel,
|
|
130
|
-
velocity:
|
|
131
|
+
velocity: travel.map(value => velocityOf(value, hook, options.velocity))
|
|
131
132
|
};
|
|
132
133
|
};
|
|
133
134
|
return { bass: curveOf('bass'), treble: curveOf('treble') };
|
|
@@ -158,7 +159,7 @@ const performPedal = (type, curve, grid, readings, mode) => {
|
|
|
158
159
|
if (readings.length === 0)
|
|
159
160
|
return [];
|
|
160
161
|
const ordered = readings.toSorted((a, b) => a.punch.rowOn - b.punch.rowOn);
|
|
161
|
-
const causeOf = (row) => ordered[Math.max(ordered
|
|
162
|
+
const causeOf = (row) => ordered[Math.max(partitionPoint(ordered, reading => reading.punch.rowOn <= row) - 1, 0)].event;
|
|
162
163
|
return levelChanges(curve.travel, { mode })
|
|
163
164
|
.filter(change => change.index > 0)
|
|
164
165
|
.map(change => ({
|
|
@@ -182,7 +183,7 @@ const perform = (events, options, roll) => {
|
|
|
182
183
|
const geometry = geometryInMm(roll.punchDiameter ?? options.punchDiameter, options.trackerBore);
|
|
183
184
|
const ports = aperturePorts(grid, readings.map(reading => reading.punch), geometry);
|
|
184
185
|
const samples = {
|
|
185
|
-
place:
|
|
186
|
+
place: grid.seconds.map((_, row) => placeOfRow(row)),
|
|
186
187
|
seconds: grid.seconds
|
|
187
188
|
};
|
|
188
189
|
const nuance = nuanceCurves(grid, ports, samples, options);
|
package/package.json
CHANGED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { MidiFile } from "midifile-ts";
|
|
2
|
-
import { Milliseconds } from "../Quantity";
|
|
3
|
-
export declare function midiTickToMilliseconds(ticks: number, microsecondsPerBeat: number, ppq: number): Milliseconds;
|
|
4
|
-
interface Span<T extends string> {
|
|
5
|
-
type: T;
|
|
6
|
-
id: string;
|
|
7
|
-
/** In ticks of the file. */
|
|
8
|
-
onset: number;
|
|
9
|
-
offset: number;
|
|
10
|
-
onsetMs: Milliseconds;
|
|
11
|
-
offsetMs: Milliseconds;
|
|
12
|
-
link?: string;
|
|
13
|
-
}
|
|
14
|
-
export interface NoteSpan extends Span<'note'> {
|
|
15
|
-
pitch: number;
|
|
16
|
-
velocity: number;
|
|
17
|
-
channel: number;
|
|
18
|
-
}
|
|
19
|
-
export interface SustainSpan extends Span<'sustain'> {
|
|
20
|
-
}
|
|
21
|
-
export interface SoftSpan extends Span<'soft'> {
|
|
22
|
-
}
|
|
23
|
-
export type AnySpan = NoteSpan | SustainSpan | SoftSpan;
|
|
24
|
-
export declare const asSpans: (file: MidiFile, readLinks?: boolean) => AnySpan[];
|
|
25
|
-
export {};
|
package/lib/readers/midiSpans.js
DELETED
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { MIDIControlEvents } from "midifile-ts";
|
|
2
|
-
import { milliseconds } from "../Quantity";
|
|
3
|
-
const isNoteOn = (event) => event.type === 'channel' && event.subtype === 'noteOn';
|
|
4
|
-
const isNoteOff = (event) => event.type === 'channel' && event.subtype === 'noteOff';
|
|
5
|
-
const isPedalOn = (event) => (event.type === 'channel'
|
|
6
|
-
&& event.subtype === 'controller'
|
|
7
|
-
&& event.controllerType === MIDIControlEvents.SUSTAIN
|
|
8
|
-
&& event.value > 63);
|
|
9
|
-
const isPedalOff = (event) => (event.type === 'channel'
|
|
10
|
-
&& event.subtype === 'controller'
|
|
11
|
-
&& event.controllerType === MIDIControlEvents.SUSTAIN
|
|
12
|
-
&& event.value <= 63);
|
|
13
|
-
const isSoftPedalOn = (event) => {
|
|
14
|
-
return event.type === 'channel'
|
|
15
|
-
&& event.subtype === 'controller'
|
|
16
|
-
&& event.controllerType === MIDIControlEvents.SOFT_PEDAL
|
|
17
|
-
&& event.value > 63;
|
|
18
|
-
};
|
|
19
|
-
const isSoftPedalOff = (event) => {
|
|
20
|
-
return event.type === 'channel'
|
|
21
|
-
&& event.subtype === 'controller'
|
|
22
|
-
&& event.controllerType === MIDIControlEvents.SOFT_PEDAL
|
|
23
|
-
&& event.value <= 63;
|
|
24
|
-
};
|
|
25
|
-
export function midiTickToMilliseconds(ticks, microsecondsPerBeat, ppq) {
|
|
26
|
-
const beats = ticks / ppq;
|
|
27
|
-
return milliseconds((beats * microsecondsPerBeat) / 1000);
|
|
28
|
-
}
|
|
29
|
-
export const asSpans = (file, readLinks = false) => {
|
|
30
|
-
const resultingSpans = [];
|
|
31
|
-
const tempoMap = [];
|
|
32
|
-
const currentSpans = [];
|
|
33
|
-
let bufferedMetaText;
|
|
34
|
-
for (let i = 0; i < file.tracks.length; i++) {
|
|
35
|
-
const track = file.tracks[i];
|
|
36
|
-
let currentTime = 0;
|
|
37
|
-
for (const event of track) {
|
|
38
|
-
currentTime += event.deltaTime;
|
|
39
|
-
if (event.type === 'meta' && event.subtype === 'setTempo') {
|
|
40
|
-
tempoMap.push({
|
|
41
|
-
atTick: currentTime,
|
|
42
|
-
microsecondsPerBeat: event.microsecondsPerBeat
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
if (readLinks && event.type === 'meta' && event.subtype === 'text') {
|
|
46
|
-
bufferedMetaText = event.text;
|
|
47
|
-
}
|
|
48
|
-
else if (isNoteOn(event) || isPedalOn(event) || isSoftPedalOn(event)) {
|
|
49
|
-
const type = isNoteOn(event) ? 'note' : isPedalOn(event) ? 'sustain' : 'soft';
|
|
50
|
-
const currentTempo = tempoMap.slice().reverse().find(tempo => tempo.atTick <= currentTime);
|
|
51
|
-
if (!currentTempo) {
|
|
52
|
-
console.log('No tempo event found. Skipping');
|
|
53
|
-
continue;
|
|
54
|
-
}
|
|
55
|
-
const onsetMs = midiTickToMilliseconds(currentTime, currentTempo.microsecondsPerBeat, file.header.ticksPerBeat);
|
|
56
|
-
const link = bufferedMetaText;
|
|
57
|
-
if (type === 'note') {
|
|
58
|
-
const pitch = event.noteNumber;
|
|
59
|
-
currentSpans.push({
|
|
60
|
-
type,
|
|
61
|
-
id: `${i}-${currentTime}-${pitch}`,
|
|
62
|
-
onset: currentTime,
|
|
63
|
-
offset: 0,
|
|
64
|
-
velocity: event.velocity,
|
|
65
|
-
pitch,
|
|
66
|
-
channel: i,
|
|
67
|
-
onsetMs,
|
|
68
|
-
offsetMs: milliseconds(0),
|
|
69
|
-
link
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
else {
|
|
73
|
-
currentSpans.push({
|
|
74
|
-
type,
|
|
75
|
-
id: `${i}-${currentTime}-${type}`,
|
|
76
|
-
onset: currentTime,
|
|
77
|
-
offset: 0,
|
|
78
|
-
onsetMs,
|
|
79
|
-
offsetMs: milliseconds(0),
|
|
80
|
-
link
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
bufferedMetaText = undefined;
|
|
84
|
-
}
|
|
85
|
-
else if (isNoteOff(event) || isPedalOff(event) || isSoftPedalOff(event)) {
|
|
86
|
-
const type = isNoteOff(event) ? 'note' : isPedalOff(event) ? 'sustain' : 'soft';
|
|
87
|
-
const currentTempo = tempoMap.slice().reverse().find(tempo => tempo.atTick <= currentTime);
|
|
88
|
-
if (!currentTempo) {
|
|
89
|
-
console.log('No tempo event found. Skipping');
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
const counterpart = isNoteOff(event)
|
|
93
|
-
? currentSpans.find(e => e.type === 'note' && e.pitch === event.noteNumber)
|
|
94
|
-
: currentSpans.find(e => e.type === type);
|
|
95
|
-
if (!counterpart) {
|
|
96
|
-
console.log('Found an off event of type', type, 'at', currentTime, 'without a previous on.', 'Event:', event, 'Current spans: ', currentSpans.map(span => span.type).join(' '));
|
|
97
|
-
continue;
|
|
98
|
-
}
|
|
99
|
-
counterpart.offset = currentTime;
|
|
100
|
-
counterpart.offsetMs = midiTickToMilliseconds(currentTime, currentTempo.microsecondsPerBeat, file.header.ticksPerBeat);
|
|
101
|
-
if (bufferedMetaText && counterpart.link) {
|
|
102
|
-
counterpart.link += ` ${bufferedMetaText}`;
|
|
103
|
-
}
|
|
104
|
-
resultingSpans.push(counterpart);
|
|
105
|
-
currentSpans.splice(currentSpans.indexOf(counterpart), 1);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
return resultingSpans
|
|
110
|
-
.filter(span => span.offsetMs > span.onsetMs)
|
|
111
|
-
.sort((a, b) => a.onset - b.onset);
|
|
112
|
-
};
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { RollCopy } from "../RollCopy";
|
|
2
|
-
import { FeetPerMinute, Millimeters, Seconds, Track } from "../Quantity";
|
|
3
|
-
/**
|
|
4
|
-
* How a MIDI key number in one of Spencer Chase's roll files names a
|
|
5
|
-
* tracker bar track.
|
|
6
|
-
*
|
|
7
|
-
* The note block follows the obvious rule, `pitch - 13`, which puts
|
|
8
|
-
* track 11 on MIDI 24 as the T100 compass requires. The bass expression
|
|
9
|
-
* block does not: it reads two tracks high, and subtracting two is what
|
|
10
|
-
* has made these files come out right so far.
|
|
11
|
-
*
|
|
12
|
-
* The boundary between the two rules is unresolved. Taken literally the
|
|
13
|
-
* rules leave tracks 8 and 9 unreachable and jump from track 7 to track 10,
|
|
14
|
-
* which no lateral offset can produce, so at least one of them is
|
|
15
|
-
* approximate. Settling it needs a Spencer file whose expression holes
|
|
16
|
-
* can be checked against the roll, hence the option to override.
|
|
17
|
-
*/
|
|
18
|
-
export declare const spencerTrackOf: (pitch: number) => Track;
|
|
19
|
-
/**
|
|
20
|
-
* Spencer Chase's rolls seem to be scanned at a roll speed of
|
|
21
|
-
* 83 (=8.3 feet per minute). A scanner feeds the paper at one
|
|
22
|
-
* speed, so time in his files is proportional to place.
|
|
23
|
-
*/
|
|
24
|
-
export declare const SPENCER_FEET_PER_MINUTE: import("..").Quantity<"ft/min">;
|
|
25
|
-
/** Place on the roll after `time` at a constant `speed`. */
|
|
26
|
-
export declare const atConstantSpeed: (speed: FeetPerMinute) => (time: Seconds) => Millimeters;
|
|
27
|
-
export declare function readFromSpencerMIDI(midiBuffer: ArrayBuffer, placeAt?: (time: Seconds) => Millimeters, trackOf?: (pitch: number) => Track): RollCopy;
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { v4 } from "uuid";
|
|
2
|
-
import { read } from "midifile-ts";
|
|
3
|
-
import { asSpans } from "./midiSpans";
|
|
4
|
-
import { feetPerMinute, inSeconds, mm, track } from "../Quantity";
|
|
5
|
-
/**
|
|
6
|
-
* How a MIDI key number in one of Spencer Chase's roll files names a
|
|
7
|
-
* tracker bar track.
|
|
8
|
-
*
|
|
9
|
-
* The note block follows the obvious rule, `pitch - 13`, which puts
|
|
10
|
-
* track 11 on MIDI 24 as the T100 compass requires. The bass expression
|
|
11
|
-
* block does not: it reads two tracks high, and subtracting two is what
|
|
12
|
-
* has made these files come out right so far.
|
|
13
|
-
*
|
|
14
|
-
* The boundary between the two rules is unresolved. Taken literally the
|
|
15
|
-
* rules leave tracks 8 and 9 unreachable and jump from track 7 to track 10,
|
|
16
|
-
* which no lateral offset can produce, so at least one of them is
|
|
17
|
-
* approximate. Settling it needs a Spencer file whose expression holes
|
|
18
|
-
* can be checked against the roll, hence the option to override.
|
|
19
|
-
*/
|
|
20
|
-
export const spencerTrackOf = (pitch) => {
|
|
21
|
-
const position = pitch - 13;
|
|
22
|
-
return track(position < 10 ? position - 2 : position);
|
|
23
|
-
};
|
|
24
|
-
const MM_PER_FOOT = 304.8;
|
|
25
|
-
const SECONDS_PER_MINUTE = 60;
|
|
26
|
-
/**
|
|
27
|
-
* Spencer Chase's rolls seem to be scanned at a roll speed of
|
|
28
|
-
* 83 (=8.3 feet per minute). A scanner feeds the paper at one
|
|
29
|
-
* speed, so time in his files is proportional to place.
|
|
30
|
-
*/
|
|
31
|
-
export const SPENCER_FEET_PER_MINUTE = feetPerMinute(8.3);
|
|
32
|
-
/** Place on the roll after `time` at a constant `speed`. */
|
|
33
|
-
export const atConstantSpeed = (speed) => (time) => mm(speed * MM_PER_FOOT / SECONDS_PER_MINUTE * time);
|
|
34
|
-
export function readFromSpencerMIDI(midiBuffer, placeAt = atConstantSpeed(SPENCER_FEET_PER_MINUTE), trackOf = spencerTrackOf) {
|
|
35
|
-
const features = asSpans(read(midiBuffer))
|
|
36
|
-
.filter(span => span.type === 'note')
|
|
37
|
-
.map((span) => ({
|
|
38
|
-
type: 'Hole',
|
|
39
|
-
id: v4(),
|
|
40
|
-
vertical: {
|
|
41
|
-
from: trackOf(span.pitch),
|
|
42
|
-
unit: 'track'
|
|
43
|
-
},
|
|
44
|
-
horizontal: {
|
|
45
|
-
from: placeAt(inSeconds(span.onsetMs)),
|
|
46
|
-
to: placeAt(inSeconds(span.offsetMs)),
|
|
47
|
-
unit: 'mm'
|
|
48
|
-
}
|
|
49
|
-
}));
|
|
50
|
-
return {
|
|
51
|
-
type: 'RollCopy',
|
|
52
|
-
id: v4(),
|
|
53
|
-
ops: [],
|
|
54
|
-
conditions: [],
|
|
55
|
-
keeper: { name: '', sameAs: [] },
|
|
56
|
-
measurements: {},
|
|
57
|
-
modifications: [],
|
|
58
|
-
features
|
|
59
|
-
};
|
|
60
|
-
}
|