linked-rolls 0.0.1
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 +18 -0
- package/lib/Collation.d.ts +4 -0
- package/lib/Collation.js +1 -0
- package/lib/Condition.d.ts +10 -0
- package/lib/Condition.js +1 -0
- package/lib/ConditionState.d.ts +9 -0
- package/lib/ConditionState.js +6 -0
- package/lib/Edit.d.ts +21 -0
- package/lib/Edit.js +112 -0
- package/lib/Edition.d.ts +54 -0
- package/lib/Edition.js +1 -0
- package/lib/EditorialAssumption.d.ts +67 -0
- package/lib/EditorialAssumption.js +26 -0
- package/lib/Emulation.d.ts +76 -0
- package/lib/Emulation.js +474 -0
- package/lib/Feature.d.ts +37 -0
- package/lib/Feature.js +3 -0
- package/lib/Measurement.d.ts +9 -0
- package/lib/Measurement.js +1 -0
- package/lib/PlaceTimeConversion.d.ts +65 -0
- package/lib/PlaceTimeConversion.js +175 -0
- package/lib/RollCopy.d.ts +85 -0
- package/lib/RollCopy.js +273 -0
- package/lib/RollEvent.d.ts +76 -0
- package/lib/RollEvent.js +3 -0
- package/lib/Stage.d.ts +37 -0
- package/lib/Stage.js +165 -0
- package/lib/Symbol.d.ts +62 -0
- package/lib/Symbol.js +29 -0
- package/lib/TrackerBar.d.ts +8 -0
- package/lib/TrackerBar.js +53 -0
- package/lib/Transcription.d.ts +7 -0
- package/lib/Transcription.js +9 -0
- package/lib/Version.d.ts +42 -0
- package/lib/Version.js +217 -0
- package/lib/WithId.d.ts +3 -0
- package/lib/WithId.js +1 -0
- package/lib/alignFeatures.d.ts +11 -0
- package/lib/alignFeatures.js +54 -0
- package/lib/alignRolls.d.ts +7 -0
- package/lib/alignRolls.js +49 -0
- package/lib/alignSymbols.d.ts +7 -0
- package/lib/alignSymbols.js +49 -0
- package/lib/asJsonLd.d.ts +3 -0
- package/lib/asJsonLd.js +67 -0
- package/lib/asMIDISpans.d.ts +23 -0
- package/lib/asMIDISpans.js +111 -0
- package/lib/aton/AtonParser.d.ts +48 -0
- package/lib/aton/AtonParser.js +245 -0
- package/lib/aton/AtonParser.test.d.ts +1 -0
- package/lib/aton/AtonParser.test.js +16 -0
- package/lib/build-schema.cjs +113 -0
- package/lib/context.d.ts +1 -0
- package/lib/context.js +1 -0
- package/lib/importJsonLd.d.ts +4 -0
- package/lib/importJsonLd.js +121 -0
- package/lib/index.d.ts +17 -0
- package/lib/index.js +17 -0
- package/lib/spec/context.json +192 -0
- package/package.json +27 -0
package/lib/Stage.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { v4 } from "uuid";
|
|
2
|
+
import { flat } from "./EditorialAssumption";
|
|
3
|
+
import { dimensionOf } from "./Symbol";
|
|
4
|
+
const versionType = [
|
|
5
|
+
/**
|
|
6
|
+
* The roll is in a state where it is used as
|
|
7
|
+
* the master roll for several new reproductions.
|
|
8
|
+
*/
|
|
9
|
+
'edition',
|
|
10
|
+
/**
|
|
11
|
+
* This denotes a stage which is specific to (early)
|
|
12
|
+
* Welte-Mignon piano rolls, where rolls inteded to
|
|
13
|
+
* be pulished are revised by a controller first. These
|
|
14
|
+
* rolls typically carry a "controlliert" stamp. The
|
|
15
|
+
* revision is done on the same date as the perforation
|
|
16
|
+
* and the date is written on the roll towards its end.
|
|
17
|
+
*/
|
|
18
|
+
'authorised-revision',
|
|
19
|
+
/**
|
|
20
|
+
* Unauthorised revisions are those, which cannot be linked
|
|
21
|
+
* to a specific controller and are likely done by
|
|
22
|
+
* a later, anonymous hand.
|
|
23
|
+
*/
|
|
24
|
+
'unauthorised-revision',
|
|
25
|
+
/**
|
|
26
|
+
* In the case of Welte Mignon rolls, glosses are
|
|
27
|
+
* typically comments about the roll's condition, added
|
|
28
|
+
* e.g. by the collector.
|
|
29
|
+
*/
|
|
30
|
+
'gloss'
|
|
31
|
+
];
|
|
32
|
+
export const traverseStages = (stage, callback) => {
|
|
33
|
+
callback(stage);
|
|
34
|
+
if (stage.basedOn) {
|
|
35
|
+
traverseStages(flat(stage.basedOn), callback);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
export const getSnapshot = (stage) => {
|
|
39
|
+
const snapshot = [];
|
|
40
|
+
const toDelete = [];
|
|
41
|
+
traverseStages(stage, s => {
|
|
42
|
+
snapshot.push(...s.edits.flatMap(edit => edit.insert || []));
|
|
43
|
+
// as we travel further up, remove symbols that are
|
|
44
|
+
// deleted in the stages further down
|
|
45
|
+
const deleted = [];
|
|
46
|
+
for (const toRemove of toDelete) {
|
|
47
|
+
const index = snapshot.findIndex(s => s === toRemove);
|
|
48
|
+
if (index !== -1) {
|
|
49
|
+
snapshot.splice(index, 1);
|
|
50
|
+
deleted.push(toRemove);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const del of deleted) {
|
|
54
|
+
toDelete.splice(toDelete.indexOf(del), 1);
|
|
55
|
+
}
|
|
56
|
+
// collect symbols that are deleted in the current stage
|
|
57
|
+
toDelete.push(...s.edits.flatMap(edit => edit.delete || []));
|
|
58
|
+
});
|
|
59
|
+
return snapshot.sort((a, b) => {
|
|
60
|
+
const aDimension = dimensionOf(a);
|
|
61
|
+
const bDimension = dimensionOf(b);
|
|
62
|
+
return aDimension.horizontal.from - bDimension.horizontal.from;
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
const isCollatable = (symbolA, symbolB) => {
|
|
66
|
+
// two symbols are collatible if they share the same
|
|
67
|
+
// symbol characteristics (pitch, expression type etc.)
|
|
68
|
+
// and occur in the same horizontal position.
|
|
69
|
+
if (symbolA.type === 'note' && symbolB.type === 'note') {
|
|
70
|
+
if (symbolA.pitch !== symbolB.pitch)
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
else if (symbolA.type === 'expression' && symbolB.type === 'expression') {
|
|
74
|
+
if (symbolA.expressionType !== symbolB.expressionType)
|
|
75
|
+
return false;
|
|
76
|
+
if (symbolA.scope !== symbolB.scope)
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
const dimensionA = dimensionOf(symbolA);
|
|
80
|
+
const dimensionB = dimensionOf(symbolB);
|
|
81
|
+
const distanceStart = Math.abs(dimensionA.horizontal.from - dimensionB.horizontal.from);
|
|
82
|
+
const distanceEnd = Math.abs(dimensionA.horizontal.to - dimensionB.horizontal.to);
|
|
83
|
+
if (distanceStart > 5 || distanceEnd > 5) {
|
|
84
|
+
// the symbols are too far apart to be collated
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
};
|
|
89
|
+
const overlaps = (a, b) => {
|
|
90
|
+
const overlapsDimension = (a, b) => (a.from ?? 0) < (b.to ?? Infinity) && (b.from ?? 0) < (a.to ?? Infinity);
|
|
91
|
+
return overlapsDimension(a.horizontal, b.horizontal) && overlapsDimension(a.vertical, b.vertical);
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* Walks through the stages. If it finds a symbol that is still
|
|
95
|
+
* part of the tradition (i.e. it is included in the current snapshot)
|
|
96
|
+
* and is equivalent with the given symbol, it will add the feature
|
|
97
|
+
* carrying the symbol to the collated symbol. Otherwise, the
|
|
98
|
+
* given symbol will be added to the current stage's insertions.
|
|
99
|
+
*
|
|
100
|
+
* All symbols of the tradition that are not included in the given
|
|
101
|
+
* symbols are considered to be deleted.
|
|
102
|
+
*
|
|
103
|
+
* @param creation
|
|
104
|
+
* @param symbols
|
|
105
|
+
* @returns
|
|
106
|
+
*/
|
|
107
|
+
export function fillEdits(currentStage, symbols) {
|
|
108
|
+
const snapshot = getSnapshot(currentStage);
|
|
109
|
+
const treatedSymbols = [];
|
|
110
|
+
// can it be collated with any of the symbols of
|
|
111
|
+
// included in the current snapshot?
|
|
112
|
+
const insertions = [...symbols];
|
|
113
|
+
for (const symbol of symbols) {
|
|
114
|
+
snapshot
|
|
115
|
+
.filter(toCompare => isCollatable(symbol, toCompare))
|
|
116
|
+
.forEach(corresp => {
|
|
117
|
+
corresp.carriers.push(...symbol.carriers);
|
|
118
|
+
insertions.splice(insertions.indexOf(symbol), 1);
|
|
119
|
+
treatedSymbols.push(corresp);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
// special treatment for covers
|
|
123
|
+
const covers = insertions.filter(symbol => symbol.type === 'cover');
|
|
124
|
+
for (const cover of covers) {
|
|
125
|
+
// find perforations in the snapshot that
|
|
126
|
+
// overlap with the cover
|
|
127
|
+
snapshot
|
|
128
|
+
.filter(symbol => symbol.type === 'note' || symbol.type === 'expression')
|
|
129
|
+
.map(dimensionOf)
|
|
130
|
+
.filter(dimension => overlaps(dimension, dimensionOf(cover)))
|
|
131
|
+
.forEach(dimension => {
|
|
132
|
+
const coverDimension = dimensionOf(cover);
|
|
133
|
+
// check if the cover partially covers the beginning
|
|
134
|
+
if (dimension.horizontal.from >= coverDimension.horizontal.from &&
|
|
135
|
+
dimension.horizontal.from <= coverDimension.horizontal.to) {
|
|
136
|
+
// the note starts where the cover ends
|
|
137
|
+
dimension.horizontal.from = coverDimension.horizontal.to;
|
|
138
|
+
}
|
|
139
|
+
// check if the cover partially covers the ending
|
|
140
|
+
if (dimension.horizontal.to >= coverDimension.horizontal.from &&
|
|
141
|
+
dimension.horizontal.to <= coverDimension.horizontal.to) {
|
|
142
|
+
// the note ends where the cover starts
|
|
143
|
+
dimension.horizontal.to = coverDimension.horizontal.from;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
insertions.splice(insertions.indexOf(cover), 1);
|
|
147
|
+
}
|
|
148
|
+
currentStage.edits.push(...insertions.map((symbol) => {
|
|
149
|
+
return {
|
|
150
|
+
insert: [symbol],
|
|
151
|
+
delete: [],
|
|
152
|
+
id: v4(),
|
|
153
|
+
};
|
|
154
|
+
}));
|
|
155
|
+
const deletions = snapshot.filter(sym => {
|
|
156
|
+
return !treatedSymbols.includes(sym);
|
|
157
|
+
});
|
|
158
|
+
for (const symbol of deletions) {
|
|
159
|
+
currentStage.edits.push({
|
|
160
|
+
insert: [],
|
|
161
|
+
delete: [symbol],
|
|
162
|
+
id: v4(),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
package/lib/Symbol.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { EditorialAssumption } from "./EditorialAssumption";
|
|
2
|
+
import { HorizontalSpan, RollFeature, VerticalSpan } from "./Feature";
|
|
3
|
+
import { WithId } from "./WithId";
|
|
4
|
+
/**
|
|
5
|
+
* @todo Should this be called Transcription?
|
|
6
|
+
*/
|
|
7
|
+
export type CarrierAssignment = EditorialAssumption<'carrierAssignment', RollFeature>;
|
|
8
|
+
export interface Symbol<T extends string> extends WithId {
|
|
9
|
+
type: T;
|
|
10
|
+
carriers: CarrierAssignment[];
|
|
11
|
+
}
|
|
12
|
+
export declare const isSymbol: (object: any) => object is AnySymbol;
|
|
13
|
+
export interface Perforation<T extends string> extends Symbol<T> {
|
|
14
|
+
accelerating?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface Note extends Perforation<'note'> {
|
|
17
|
+
pitch: number;
|
|
18
|
+
}
|
|
19
|
+
export type ExpressionScope = 'bass' | 'treble';
|
|
20
|
+
export type ExpressionType = 'SustainPedalOn' | 'SustainPedalOff' | 'SoftPedalOn' | 'SoftPedalOff' | 'MezzoforteOff' | 'MezzoforteOn' | 'SlowCrescendoOn' | 'SlowCrescendoOff' | 'ForzandoOn' | 'ForzandoOff' | 'MotorOff' | 'MotorOn' | 'Rewind' | 'ElectricCutOff';
|
|
21
|
+
export interface Expression extends Perforation<'expression'> {
|
|
22
|
+
scope: ExpressionScope;
|
|
23
|
+
expressionType: ExpressionType;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* This denotes perforations that are covered by an editor.
|
|
27
|
+
* The covered perforation is not considered to be part
|
|
28
|
+
* of the original note or expression hole anymore.
|
|
29
|
+
*/
|
|
30
|
+
export interface Cover extends Symbol<'cover'> {
|
|
31
|
+
/**
|
|
32
|
+
* This property can be used to indicate e.g. the
|
|
33
|
+
* color or material of the cover.
|
|
34
|
+
*/
|
|
35
|
+
note?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface Text<T extends string> extends Symbol<T> {
|
|
38
|
+
text: string;
|
|
39
|
+
rotation?: number;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* For handwritten insertions like e. g. the
|
|
43
|
+
* perforation date in the end of a roll.
|
|
44
|
+
*/
|
|
45
|
+
export interface HandwrittenText extends Text<'handwrittenText'> {
|
|
46
|
+
pen?: 'ink' | 'pencil' | 'crayon';
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* This type can be used to indicate stamps like e. g. the
|
|
50
|
+
* "controlliert" stamp in the beginning of rolls or the
|
|
51
|
+
* date at the end of (later) Welte rolls.
|
|
52
|
+
*/
|
|
53
|
+
export interface Stamp extends Text<'stamp'> {
|
|
54
|
+
}
|
|
55
|
+
export interface RollLabel extends Text<'rollLabel'> {
|
|
56
|
+
signed: boolean;
|
|
57
|
+
}
|
|
58
|
+
export type AnySymbol = Note | Expression | HandwrittenText | Stamp | Cover | RollLabel;
|
|
59
|
+
export declare const dimensionOf: (symbol: AnySymbol) => {
|
|
60
|
+
horizontal: HorizontalSpan;
|
|
61
|
+
vertical: VerticalSpan;
|
|
62
|
+
};
|
package/lib/Symbol.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { flat } from "./EditorialAssumption";
|
|
2
|
+
export const isSymbol = (object) => {
|
|
3
|
+
return 'carriers' in object;
|
|
4
|
+
};
|
|
5
|
+
export const dimensionOf = (symbol) => {
|
|
6
|
+
if (symbol.carriers.length === 0) {
|
|
7
|
+
return {
|
|
8
|
+
horizontal: { unit: 'mm', from: 0, to: 0 },
|
|
9
|
+
vertical: { unit: 'track', from: 0, to: 0 }
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
const horizontalFrom = flat(symbol.carriers)
|
|
13
|
+
.reduce((hAcc, h) => hAcc + h.horizontal.from, 0) / symbol.carriers.length;
|
|
14
|
+
const horizontalTo = flat(symbol.carriers)
|
|
15
|
+
.reduce((hAcc, h) => hAcc + h.horizontal.to, 0) / symbol.carriers.length;
|
|
16
|
+
const verticalFrom = flat(symbol.carriers)
|
|
17
|
+
.reduce((vAcc, v) => vAcc + v.vertical.from, 0) / symbol.carriers.length;
|
|
18
|
+
const definedVerticalTo = flat(symbol.carriers)
|
|
19
|
+
.filter(v => v.vertical.to !== undefined);
|
|
20
|
+
let verticalTo = undefined;
|
|
21
|
+
if (definedVerticalTo.length > 0) {
|
|
22
|
+
verticalTo = definedVerticalTo
|
|
23
|
+
.reduce((vAcc, v) => vAcc + v.vertical.to, 0) / definedVerticalTo.length;
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
horizontal: { unit: 'mm', from: horizontalFrom, to: horizontalTo },
|
|
27
|
+
vertical: { unit: 'track', from: verticalFrom, to: verticalTo }
|
|
28
|
+
};
|
|
29
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Expression, Note } from "./Symbol";
|
|
2
|
+
export declare abstract class TrackerBar {
|
|
3
|
+
abstract meaningOf(track: number): Partial<Note> | Partial<Expression>;
|
|
4
|
+
}
|
|
5
|
+
export declare class WelteT100 {
|
|
6
|
+
meaningOf(track: number): Pick<Note, 'type' | 'pitch'> | Pick<Expression, 'type' | 'expressionType' | 'scope'>;
|
|
7
|
+
width: number;
|
|
8
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export class TrackerBar {
|
|
2
|
+
}
|
|
3
|
+
export class WelteT100 {
|
|
4
|
+
constructor() {
|
|
5
|
+
Object.defineProperty(this, "width", {
|
|
6
|
+
enumerable: true,
|
|
7
|
+
configurable: true,
|
|
8
|
+
writable: true,
|
|
9
|
+
value: 328
|
|
10
|
+
}); // in mm, cf. Hagmann, p. 75
|
|
11
|
+
}
|
|
12
|
+
meaningOf(track) {
|
|
13
|
+
if (track <= 0 || track > 100) {
|
|
14
|
+
throw new Error('Track out of range');
|
|
15
|
+
}
|
|
16
|
+
// Cf. Hagmann, p. 178
|
|
17
|
+
const expressionMap = new Map([
|
|
18
|
+
[1, 'MezzoforteOff'],
|
|
19
|
+
[2, 'MezzoforteOn'],
|
|
20
|
+
[3, 'SlowCrescendoOff'],
|
|
21
|
+
[4, 'SlowCrescendoOn'],
|
|
22
|
+
[5, 'ForzandoOff'],
|
|
23
|
+
[6, 'ForzandoOn'],
|
|
24
|
+
[7, 'SoftPedalOff'],
|
|
25
|
+
[8, 'SoftPedalOn'],
|
|
26
|
+
[9, 'MotorOff'],
|
|
27
|
+
[10, 'MotorOn'],
|
|
28
|
+
[91, 'Rewind'],
|
|
29
|
+
[92, 'ElectricCutOff'],
|
|
30
|
+
[93, 'SustainPedalOn'],
|
|
31
|
+
[94, 'SustainPedalOff'],
|
|
32
|
+
[95, 'ForzandoOn'],
|
|
33
|
+
[96, 'ForzandoOff'],
|
|
34
|
+
[97, 'SlowCrescendoOn'],
|
|
35
|
+
[98, 'SlowCrescendoOff'],
|
|
36
|
+
[99, 'MezzoforteOn'],
|
|
37
|
+
[100, 'MezzoforteOff']
|
|
38
|
+
]);
|
|
39
|
+
if (track <= 10 || track >= 91) {
|
|
40
|
+
const scope = track <= 10 ? 'bass' : 'treble';
|
|
41
|
+
return {
|
|
42
|
+
expressionType: expressionMap.get(track),
|
|
43
|
+
scope,
|
|
44
|
+
type: 'expression'
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
// C1-G4 = track 11-90 = 24-103 in MIDI keys
|
|
48
|
+
return {
|
|
49
|
+
pitch: track + 13,
|
|
50
|
+
type: 'note',
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export {};
|
|
2
|
+
// Example:
|
|
3
|
+
// Edit -> carried out by -> "Anonym"
|
|
4
|
+
// Edit -> concluded by -> Observation: der äußeren Erscheinung nach eine spätere Hinzufügung von unbekanntere Hand
|
|
5
|
+
// Edit -> insert -> Symbol
|
|
6
|
+
// Symbol -> is carried by -> Roll Feature -> condition -> damaged
|
|
7
|
+
// Symbol -> concludedBy -> Transcription -> Die Perforationen wurden nicht präzise an die richtige Stelle gesetzt.
|
|
8
|
+
// Wir gehen davon aus, dass sie jeweils auf der darüberliegenden Linie
|
|
9
|
+
// platziert werden sollten.
|
package/lib/Version.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { ActorAssignment, Edit } from "./Edit";
|
|
2
|
+
import { EditorialAssumption, Motivation } from "./EditorialAssumption";
|
|
3
|
+
import { AnySymbol } from "./Symbol";
|
|
4
|
+
import { CollationTolerance } from "./Collation";
|
|
5
|
+
export type Derivation = EditorialAssumption<'derivation', Version>;
|
|
6
|
+
export declare const versionTypes: readonly ["edition", "authorised-revision", "unauthorised-revision", "gloss"];
|
|
7
|
+
export type VersionType = typeof versionTypes[number];
|
|
8
|
+
/**
|
|
9
|
+
* Version + Version Creation
|
|
10
|
+
*/
|
|
11
|
+
export interface Version {
|
|
12
|
+
id: string;
|
|
13
|
+
siglum: string;
|
|
14
|
+
actor?: ActorAssignment;
|
|
15
|
+
basedOn?: Derivation;
|
|
16
|
+
edits: Edit[];
|
|
17
|
+
motivations: Motivation<string>[];
|
|
18
|
+
type: VersionType;
|
|
19
|
+
}
|
|
20
|
+
export declare const traverseVersions: (version: Version, callback: (version: Version) => void) => void;
|
|
21
|
+
export declare const getSnapshot: (version: Version) => AnySymbol[];
|
|
22
|
+
/**
|
|
23
|
+
* Walks through the versions. If it finds a symbol that is still
|
|
24
|
+
* part of the tradition (i.e. it is included in the current snapshot)
|
|
25
|
+
* and is equivalent with the given symbol, it will add the feature
|
|
26
|
+
* carrying the symbol to the collated symbol. Otherwise, the
|
|
27
|
+
* given symbol will be added to the current version's insertions.
|
|
28
|
+
*
|
|
29
|
+
* All symbols of the tradition that are not included in the given
|
|
30
|
+
* symbols are considered to be deleted.
|
|
31
|
+
*
|
|
32
|
+
* @param creation
|
|
33
|
+
* @param symbols
|
|
34
|
+
* @returns
|
|
35
|
+
*/
|
|
36
|
+
export declare function fillEdits(currentVersion: Version, symbols: AnySymbol[], tolerance?: CollationTolerance): void;
|
|
37
|
+
/**
|
|
38
|
+
* Assigns a generation (depth) to every node.
|
|
39
|
+
*/
|
|
40
|
+
export declare function assignGenerations(versions: Version[]): Array<Version & {
|
|
41
|
+
generation: number;
|
|
42
|
+
}>;
|
package/lib/Version.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { v4 } from "uuid";
|
|
2
|
+
import { flat } from "./EditorialAssumption";
|
|
3
|
+
import { dimensionOf } from "./Symbol";
|
|
4
|
+
export const versionTypes = [
|
|
5
|
+
/**
|
|
6
|
+
* The roll is in a state where it is used as
|
|
7
|
+
* the master roll for several new reproductions.
|
|
8
|
+
*/
|
|
9
|
+
'edition',
|
|
10
|
+
/**
|
|
11
|
+
* This denotes a version which is specific to (early)
|
|
12
|
+
* Welte-Mignon piano rolls, where rolls inteded to
|
|
13
|
+
* be pulished are revised by a controller first. These
|
|
14
|
+
* rolls typically carry a "controlliert" stamp. The
|
|
15
|
+
* revision is done on the same date as the perforation
|
|
16
|
+
* and the date is written on the roll towards its end.
|
|
17
|
+
*/
|
|
18
|
+
'authorised-revision',
|
|
19
|
+
/**
|
|
20
|
+
* Unauthorised revisions are those, which cannot be linked
|
|
21
|
+
* to a specific controller and are likely done by
|
|
22
|
+
* a later, anonymous hand.
|
|
23
|
+
*/
|
|
24
|
+
'unauthorised-revision',
|
|
25
|
+
/**
|
|
26
|
+
* In the case of Welte Mignon rolls, glosses are
|
|
27
|
+
* typically comments about the roll's condition, added
|
|
28
|
+
* e.g. by the collector.
|
|
29
|
+
*/
|
|
30
|
+
'gloss'
|
|
31
|
+
];
|
|
32
|
+
export const traverseVersions = (version, callback) => {
|
|
33
|
+
callback(version);
|
|
34
|
+
if (version.basedOn) {
|
|
35
|
+
traverseVersions(flat(version.basedOn), callback);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
export const getSnapshot = (version) => {
|
|
39
|
+
const snapshot = [];
|
|
40
|
+
const toDelete = [];
|
|
41
|
+
traverseVersions(version, s => {
|
|
42
|
+
snapshot.push(...s.edits.flatMap(edit => edit.insert || []));
|
|
43
|
+
// as we travel further up, remove symbols that are
|
|
44
|
+
// deleted in the versions further down
|
|
45
|
+
const deleted = [];
|
|
46
|
+
for (const toRemove of toDelete) {
|
|
47
|
+
const index = snapshot.findIndex(s => s === toRemove);
|
|
48
|
+
if (index !== -1) {
|
|
49
|
+
snapshot.splice(index, 1);
|
|
50
|
+
deleted.push(toRemove);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
for (const del of deleted) {
|
|
54
|
+
toDelete.splice(toDelete.indexOf(del), 1);
|
|
55
|
+
}
|
|
56
|
+
// collect symbols that are deleted in the current version
|
|
57
|
+
toDelete.push(...s.edits.flatMap(edit => edit.delete || []));
|
|
58
|
+
});
|
|
59
|
+
return snapshot.sort((a, b) => {
|
|
60
|
+
const aDimension = dimensionOf(a);
|
|
61
|
+
const bDimension = dimensionOf(b);
|
|
62
|
+
return aDimension.horizontal.from - bDimension.horizontal.from;
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
const isCollatable = (symbolA, symbolB, tolerance = {
|
|
66
|
+
toleranceEnd: 5,
|
|
67
|
+
toleranceStart: 5
|
|
68
|
+
}) => {
|
|
69
|
+
// two symbols are collatible if they share the same
|
|
70
|
+
// symbol characteristics (pitch, expression type etc.)
|
|
71
|
+
// and occur in the same horizontal position.
|
|
72
|
+
if (symbolA.type === 'note' && symbolB.type === 'note') {
|
|
73
|
+
if (symbolA.pitch !== symbolB.pitch)
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
else if (symbolA.type === 'expression' && symbolB.type === 'expression') {
|
|
77
|
+
if (symbolA.expressionType !== symbolB.expressionType)
|
|
78
|
+
return false;
|
|
79
|
+
if (symbolA.scope !== symbolB.scope)
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
const dimensionA = dimensionOf(symbolA);
|
|
83
|
+
const dimensionB = dimensionOf(symbolB);
|
|
84
|
+
const distanceStart = Math.abs(dimensionA.horizontal.from - dimensionB.horizontal.from);
|
|
85
|
+
const distanceEnd = Math.abs(dimensionA.horizontal.to - dimensionB.horizontal.to);
|
|
86
|
+
if (distanceStart > tolerance.toleranceStart
|
|
87
|
+
|| distanceEnd > tolerance.toleranceEnd) {
|
|
88
|
+
// the symbols are too far apart to be collated
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return true;
|
|
92
|
+
};
|
|
93
|
+
const overlaps = (a, b) => {
|
|
94
|
+
const overlapsDimension = (a, b) => (a.from ?? 0) < (b.to ?? Infinity) && (b.from ?? 0) < (a.to ?? Infinity);
|
|
95
|
+
return overlapsDimension(a.horizontal, b.horizontal) && overlapsDimension(a.vertical, b.vertical);
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Walks through the versions. If it finds a symbol that is still
|
|
99
|
+
* part of the tradition (i.e. it is included in the current snapshot)
|
|
100
|
+
* and is equivalent with the given symbol, it will add the feature
|
|
101
|
+
* carrying the symbol to the collated symbol. Otherwise, the
|
|
102
|
+
* given symbol will be added to the current version's insertions.
|
|
103
|
+
*
|
|
104
|
+
* All symbols of the tradition that are not included in the given
|
|
105
|
+
* symbols are considered to be deleted.
|
|
106
|
+
*
|
|
107
|
+
* @param creation
|
|
108
|
+
* @param symbols
|
|
109
|
+
* @returns
|
|
110
|
+
*/
|
|
111
|
+
export function fillEdits(currentVersion, symbols, tolerance = {
|
|
112
|
+
toleranceEnd: 5,
|
|
113
|
+
toleranceStart: 5
|
|
114
|
+
}) {
|
|
115
|
+
const snapshot = getSnapshot(currentVersion);
|
|
116
|
+
const treatedSymbols = [];
|
|
117
|
+
// can it be collated with any of the symbols of
|
|
118
|
+
// included in the current snapshot?
|
|
119
|
+
const insertions = [...symbols];
|
|
120
|
+
for (const symbol of symbols) {
|
|
121
|
+
snapshot
|
|
122
|
+
.filter(toCompare => isCollatable(symbol, toCompare, tolerance))
|
|
123
|
+
.forEach(corresp => {
|
|
124
|
+
corresp.carriers.push(...symbol.carriers);
|
|
125
|
+
insertions.splice(insertions.indexOf(symbol), 1);
|
|
126
|
+
treatedSymbols.push(corresp);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
// special treatment for covers
|
|
130
|
+
const covers = insertions.filter(symbol => symbol.type === 'cover');
|
|
131
|
+
for (const cover of covers) {
|
|
132
|
+
// find perforations in the snapshot that
|
|
133
|
+
// overlap with the cover
|
|
134
|
+
snapshot
|
|
135
|
+
.filter(symbol => symbol.type === 'note' || symbol.type === 'expression')
|
|
136
|
+
.map(dimensionOf)
|
|
137
|
+
.filter(dimension => overlaps(dimension, dimensionOf(cover)))
|
|
138
|
+
.forEach(dimension => {
|
|
139
|
+
const coverDimension = dimensionOf(cover);
|
|
140
|
+
// check if the cover partially covers the beginning
|
|
141
|
+
if (dimension.horizontal.from >= coverDimension.horizontal.from &&
|
|
142
|
+
dimension.horizontal.from <= coverDimension.horizontal.to) {
|
|
143
|
+
// the note starts where the cover ends
|
|
144
|
+
dimension.horizontal.from = coverDimension.horizontal.to;
|
|
145
|
+
}
|
|
146
|
+
// check if the cover partially covers the ending
|
|
147
|
+
if (dimension.horizontal.to >= coverDimension.horizontal.from &&
|
|
148
|
+
dimension.horizontal.to <= coverDimension.horizontal.to) {
|
|
149
|
+
// the note ends where the cover starts
|
|
150
|
+
dimension.horizontal.to = coverDimension.horizontal.from;
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
insertions.splice(insertions.indexOf(cover), 1);
|
|
154
|
+
}
|
|
155
|
+
currentVersion.edits.push(...insertions.map((symbol) => {
|
|
156
|
+
return {
|
|
157
|
+
insert: [symbol],
|
|
158
|
+
delete: [],
|
|
159
|
+
id: v4(),
|
|
160
|
+
};
|
|
161
|
+
}));
|
|
162
|
+
const deletions = snapshot.filter(sym => {
|
|
163
|
+
return !treatedSymbols.includes(sym);
|
|
164
|
+
});
|
|
165
|
+
for (const symbol of deletions) {
|
|
166
|
+
currentVersion.edits.push({
|
|
167
|
+
insert: [],
|
|
168
|
+
delete: [symbol],
|
|
169
|
+
id: v4(),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Assigns a generation (depth) to every node.
|
|
175
|
+
*/
|
|
176
|
+
export function assignGenerations(versions) {
|
|
177
|
+
const byId = new Map();
|
|
178
|
+
for (const n of versions)
|
|
179
|
+
byId.set(n.id, n);
|
|
180
|
+
const memo = new Map(); // id -> generation
|
|
181
|
+
const inStack = new Set(); // for cycle detection
|
|
182
|
+
const computeGen = (id) => {
|
|
183
|
+
if (memo.has(id))
|
|
184
|
+
return memo.get(id);
|
|
185
|
+
if (inStack.has(id)) {
|
|
186
|
+
throw new Error(`Cycle detected involving node '${id}'. Check parentId links.`);
|
|
187
|
+
}
|
|
188
|
+
const node = byId.get(id);
|
|
189
|
+
if (!node) {
|
|
190
|
+
// If you ever call computeGen on a missing id, treat as root.
|
|
191
|
+
memo.set(id, 0);
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
inStack.add(id);
|
|
195
|
+
let gen;
|
|
196
|
+
const p = node.basedOn?.assigned.id;
|
|
197
|
+
if (p === undefined) {
|
|
198
|
+
gen = 0; // root
|
|
199
|
+
}
|
|
200
|
+
else if (!byId.has(p)) {
|
|
201
|
+
// Orphaned parent reference — treat boundary as root
|
|
202
|
+
gen = 0;
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
gen = 1 + computeGen(p);
|
|
206
|
+
}
|
|
207
|
+
inStack.delete(id);
|
|
208
|
+
memo.set(id, gen);
|
|
209
|
+
return gen;
|
|
210
|
+
};
|
|
211
|
+
// Compute for all nodes (order doesn’t matter)
|
|
212
|
+
const withGen = versions.map(n => ({
|
|
213
|
+
...n,
|
|
214
|
+
generation: computeGen(n.id),
|
|
215
|
+
}));
|
|
216
|
+
return withGen;
|
|
217
|
+
}
|
package/lib/WithId.d.ts
ADDED
package/lib/WithId.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { RollFeature } from "./Feature";
|
|
2
|
+
type AlignmentResult = {
|
|
3
|
+
shift: number;
|
|
4
|
+
stretch: number;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* Align two rolls by computing independent linear fits of each roll's note-onset positions
|
|
8
|
+
* using only the first and last segments, then deriving a transform x2 = (x1 + shift) * stretch.
|
|
9
|
+
*/
|
|
10
|
+
export declare function alignFeatures(rollA: RollFeature[], rollB: RollFeature[]): AlignmentResult;
|
|
11
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { WelteT100 } from "./TrackerBar";
|
|
2
|
+
const isNote = (feature) => {
|
|
3
|
+
return new WelteT100().meaningOf(feature.vertical.from).type === 'note';
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Fit a line: position = alpha * index + beta via least squares.
|
|
7
|
+
*/
|
|
8
|
+
function fitIndexToPosition(indices, positions) {
|
|
9
|
+
const n = indices.length;
|
|
10
|
+
const meanIdx = indices.reduce((s, i) => s + i, 0) / n;
|
|
11
|
+
const meanPos = positions.reduce((s, p) => s + p, 0) / n;
|
|
12
|
+
let num = 0;
|
|
13
|
+
let den = 0;
|
|
14
|
+
for (let i = 0; i < n; i++) {
|
|
15
|
+
const d = indices[i] - meanIdx;
|
|
16
|
+
num += d * (positions[i] - meanPos);
|
|
17
|
+
den += d * d;
|
|
18
|
+
}
|
|
19
|
+
const alpha = den === 0 ? 1 : num / den;
|
|
20
|
+
const beta = meanPos - alpha * meanIdx;
|
|
21
|
+
return { alpha, beta };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Selects the first and last N elements of an array (or fewer if length is smaller).
|
|
25
|
+
*/
|
|
26
|
+
function selectEnds(arr, count) {
|
|
27
|
+
const n = arr.length;
|
|
28
|
+
if (count * 2 >= n)
|
|
29
|
+
return arr.slice();
|
|
30
|
+
return arr.slice(0, count).concat(arr.slice(n - count, n));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Align two rolls by computing independent linear fits of each roll's note-onset positions
|
|
34
|
+
* using only the first and last segments, then deriving a transform x2 = (x1 + shift) * stretch.
|
|
35
|
+
*/
|
|
36
|
+
export function alignFeatures(rollA, rollB) {
|
|
37
|
+
// 1. Extract note-onset positions
|
|
38
|
+
const allXA = rollA.filter(isNote).map(f => f.horizontal.from);
|
|
39
|
+
const allXB = rollB.filter(isNote).map(f => f.horizontal.from);
|
|
40
|
+
// 2. Determine segment size (e.g. 10% of notes, min 5)
|
|
41
|
+
const segCount = Math.max(5, Math.floor(allXA.length * 0.1));
|
|
42
|
+
// 3. Select only first and last segments
|
|
43
|
+
const XA = selectEnds(allXA, segCount);
|
|
44
|
+
const idxA = XA.map((_, i) => i);
|
|
45
|
+
const XB = selectEnds(allXB, segCount);
|
|
46
|
+
const idxB = XB.map((_, i) => i);
|
|
47
|
+
// 4. Fit index->position for each roll on selected ends
|
|
48
|
+
const { alpha: alphaA, beta: betaA } = fitIndexToPosition(idxA, XA);
|
|
49
|
+
const { alpha: alphaB, beta: betaB } = fitIndexToPosition(idxB, XB);
|
|
50
|
+
// 5. Derive stretch and shift such that x2 = (x1 + shift) * stretch
|
|
51
|
+
const stretch = alphaB / alphaA;
|
|
52
|
+
const shift = betaB / stretch - betaA;
|
|
53
|
+
return { stretch, shift };
|
|
54
|
+
}
|