@sudobility/music_codecs 0.1.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/dist/index.d.ts +43 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +46 -0
- package/dist/index.js.map +1 -0
- package/dist/midi/analyze.d.ts +53 -0
- package/dist/midi/analyze.d.ts.map +1 -0
- package/dist/midi/analyze.js +58 -0
- package/dist/midi/analyze.js.map +1 -0
- package/dist/midi/export.d.ts +20 -0
- package/dist/midi/export.d.ts.map +1 -0
- package/dist/midi/export.js +135 -0
- package/dist/midi/export.js.map +1 -0
- package/dist/midi/grid-detection.d.ts +41 -0
- package/dist/midi/grid-detection.d.ts.map +1 -0
- package/dist/midi/grid-detection.js +61 -0
- package/dist/midi/grid-detection.js.map +1 -0
- package/dist/midi/import-options.d.ts +45 -0
- package/dist/midi/import-options.d.ts.map +1 -0
- package/dist/midi/import-options.js +38 -0
- package/dist/midi/import-options.js.map +1 -0
- package/dist/midi/import.d.ts +43 -0
- package/dist/midi/import.d.ts.map +1 -0
- package/dist/midi/import.js +441 -0
- package/dist/midi/import.js.map +1 -0
- package/dist/midi/key-detection.d.ts +11 -0
- package/dist/midi/key-detection.d.ts.map +1 -0
- package/dist/midi/key-detection.js +96 -0
- package/dist/midi/key-detection.js.map +1 -0
- package/dist/midi/measures.d.ts +39 -0
- package/dist/midi/measures.d.ts.map +1 -0
- package/dist/midi/measures.js +141 -0
- package/dist/midi/measures.js.map +1 -0
- package/dist/mod/export.d.ts +54 -0
- package/dist/mod/export.d.ts.map +1 -0
- package/dist/mod/export.js +172 -0
- package/dist/mod/export.js.map +1 -0
- package/dist/mod/fill.d.ts +11 -0
- package/dist/mod/fill.d.ts.map +1 -0
- package/dist/mod/fill.js +50 -0
- package/dist/mod/fill.js.map +1 -0
- package/dist/mod/import.d.ts +14 -0
- package/dist/mod/import.d.ts.map +1 -0
- package/dist/mod/import.js +182 -0
- package/dist/mod/import.js.map +1 -0
- package/dist/mod/limits.d.ts +26 -0
- package/dist/mod/limits.d.ts.map +1 -0
- package/dist/mod/limits.js +14 -0
- package/dist/mod/limits.js.map +1 -0
- package/dist/mod/timing.d.ts +29 -0
- package/dist/mod/timing.d.ts.map +1 -0
- package/dist/mod/timing.js +61 -0
- package/dist/mod/timing.js.map +1 -0
- package/dist/mod/types.d.ts +8 -0
- package/dist/mod/types.d.ts.map +1 -0
- package/dist/mod/types.js +2 -0
- package/dist/mod/types.js.map +1 -0
- package/dist/musicxml/duration-map.d.ts +34 -0
- package/dist/musicxml/duration-map.d.ts.map +1 -0
- package/dist/musicxml/duration-map.js +79 -0
- package/dist/musicxml/duration-map.js.map +1 -0
- package/dist/musicxml/export.d.ts +35 -0
- package/dist/musicxml/export.d.ts.map +1 -0
- package/dist/musicxml/export.js +271 -0
- package/dist/musicxml/export.js.map +1 -0
- package/dist/musicxml/import.d.ts +52 -0
- package/dist/musicxml/import.d.ts.map +1 -0
- package/dist/musicxml/import.js +637 -0
- package/dist/musicxml/import.js.map +1 -0
- package/dist/test/fixtures.d.ts +45 -0
- package/dist/test/fixtures.d.ts.map +1 -0
- package/dist/test/fixtures.js +420 -0
- package/dist/test/fixtures.js.map +1 -0
- package/dist/test/musicxml-warnings.d.ts +11 -0
- package/dist/test/musicxml-warnings.d.ts.map +1 -0
- package/dist/test/musicxml-warnings.js +26 -0
- package/dist/test/musicxml-warnings.js.map +1 -0
- package/dist/test/platform.d.ts +13 -0
- package/dist/test/platform.d.ts.map +1 -0
- package/dist/test/platform.js +191 -0
- package/dist/test/platform.js.map +1 -0
- package/package.json +71 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Measure-timeline assembly for MIDI import (spec §15): turns a
|
|
3
|
+
* time-signature change list + a score end tick into consecutive `Measure`
|
|
4
|
+
* spans, and assembles a track's per-voice absolute-tick note events into
|
|
5
|
+
* `Measure[]` (splitting notes across measure boundaries with ties, filling
|
|
6
|
+
* silent gaps with rests). Kept independent of `@tonejs/midi` so it's usable
|
|
7
|
+
* (and unit-testable) purely in terms of the domain model.
|
|
8
|
+
*/
|
|
9
|
+
import { createId } from '@sudobility/music_types';
|
|
10
|
+
import { splitNoteAcrossMeasures } from '@sudobility/music_types';
|
|
11
|
+
import { measureDurationTicks } from '@sudobility/music_types';
|
|
12
|
+
export const DEFAULT_TIME_SIGNATURE = {
|
|
13
|
+
numerator: 4,
|
|
14
|
+
denominator: 4,
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Computes consecutive measure spans covering `[0, endTick)` from a set of
|
|
18
|
+
* time-signature changes (need not include a tick-0 entry — one is
|
|
19
|
+
* synthesized at `DEFAULT_TIME_SIGNATURE` if missing; need not be sorted).
|
|
20
|
+
* Always returns at least one span (a silent/empty file still gets a single
|
|
21
|
+
* default-meter measure, mirroring `createEmptyScore`'s "at least one
|
|
22
|
+
* measure" convention).
|
|
23
|
+
*
|
|
24
|
+
* A change tick that doesn't land exactly on a measure boundary of the
|
|
25
|
+
* preceding meter (uncommon, but not disallowed by the MIDI format) is
|
|
26
|
+
* absorbed into whichever measure is in progress when it's reached, rather
|
|
27
|
+
* than truncating a partial measure — a documented simplification, not a
|
|
28
|
+
* full mid-measure meter change.
|
|
29
|
+
*/
|
|
30
|
+
export function buildMeasureSpans(changes, ppq, endTick) {
|
|
31
|
+
const sorted = [...changes].sort((a, b) => a.tick - b.tick);
|
|
32
|
+
const withOrigin = sorted.length > 0 && sorted[0].tick === 0
|
|
33
|
+
? sorted
|
|
34
|
+
: [{ tick: 0, timeSignature: DEFAULT_TIME_SIGNATURE }, ...sorted];
|
|
35
|
+
const spans = [];
|
|
36
|
+
let cursor = 0;
|
|
37
|
+
let index = 0;
|
|
38
|
+
for (let i = 0; i < withOrigin.length; i += 1) {
|
|
39
|
+
const timeSignature = withOrigin[i].timeSignature;
|
|
40
|
+
const measureTicks = measureDurationTicks(timeSignature, ppq);
|
|
41
|
+
if (measureTicks <= 0)
|
|
42
|
+
continue;
|
|
43
|
+
const isLastSegment = i === withOrigin.length - 1;
|
|
44
|
+
const segmentEnd = isLastSegment
|
|
45
|
+
? Math.max(endTick, cursor + measureTicks)
|
|
46
|
+
: withOrigin[i + 1].tick;
|
|
47
|
+
while (cursor < segmentEnd) {
|
|
48
|
+
spans.push({
|
|
49
|
+
index,
|
|
50
|
+
startTick: cursor,
|
|
51
|
+
durationTicks: measureTicks,
|
|
52
|
+
timeSignature,
|
|
53
|
+
});
|
|
54
|
+
cursor += measureTicks;
|
|
55
|
+
index += 1;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (spans.length === 0) {
|
|
59
|
+
const measureTicks = measureDurationTicks(DEFAULT_TIME_SIGNATURE, ppq);
|
|
60
|
+
spans.push({
|
|
61
|
+
index: 0,
|
|
62
|
+
startTick: 0,
|
|
63
|
+
durationTicks: measureTicks,
|
|
64
|
+
timeSignature: DEFAULT_TIME_SIGNATURE,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return spans;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Fills the gaps in one measure-voice's already-non-overlapping notes with
|
|
71
|
+
* fresh `RestEvent`s so the voice's events cover `[startTick, startTick +
|
|
72
|
+
* durationTicks)` exactly (spec §23's "voice content sums exactly to
|
|
73
|
+
* measure duration" invariant). Notes sharing an identical `startTick`
|
|
74
|
+
* (chords) are left as-is; `notes` need not be pre-sorted.
|
|
75
|
+
*/
|
|
76
|
+
function fillVoiceGaps(notes, startTick, durationTicks, trackId, voiceId) {
|
|
77
|
+
const sorted = [...notes].sort((a, b) => a.startTick - b.startTick);
|
|
78
|
+
const events = [];
|
|
79
|
+
const endTick = startTick + durationTicks;
|
|
80
|
+
let cursor = startTick;
|
|
81
|
+
for (const note of sorted) {
|
|
82
|
+
if (note.startTick > cursor) {
|
|
83
|
+
events.push({
|
|
84
|
+
id: createId(),
|
|
85
|
+
startTick: cursor,
|
|
86
|
+
durationTicks: note.startTick - cursor,
|
|
87
|
+
voiceId,
|
|
88
|
+
trackId,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
events.push({ ...note, trackId, voiceId });
|
|
92
|
+
cursor = Math.max(cursor, note.startTick + note.durationTicks);
|
|
93
|
+
}
|
|
94
|
+
if (cursor < endTick) {
|
|
95
|
+
events.push({
|
|
96
|
+
id: createId(),
|
|
97
|
+
startTick: cursor,
|
|
98
|
+
durationTicks: endTick - cursor,
|
|
99
|
+
voiceId,
|
|
100
|
+
trackId,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
return events;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Assembles one track's `Measure[]` from `voiceLanes` — each inner array a
|
|
107
|
+
* "notated voice" of mutually non-overlapping, absolute-tick `NoteEvent`s
|
|
108
|
+
* (as produced by `allocateVoices`) — over `spans`. A note spanning more
|
|
109
|
+
* than one span is split at every internal span boundary via
|
|
110
|
+
* `splitNoteAcrossMeasures` (producing a tied chain); each span's voice
|
|
111
|
+
* content is then gap-filled with rests. An empty `voiceLanes` (a track
|
|
112
|
+
* with no notes at all) still produces one fully-rested voice per measure,
|
|
113
|
+
* satisfying the "every measure has >=1 voice per track" invariant.
|
|
114
|
+
*/
|
|
115
|
+
export function assembleTrackMeasures(voiceLanes, spans, keySignature, trackId) {
|
|
116
|
+
const boundaries = spans.map(s => s.startTick);
|
|
117
|
+
const lanes = voiceLanes.length > 0 ? voiceLanes : [[]];
|
|
118
|
+
const splitLanes = lanes.map(lane => lane.flatMap(note => splitNoteAcrossMeasures(note, boundaries)));
|
|
119
|
+
return spans.map(span => {
|
|
120
|
+
const spanEnd = span.startTick + span.durationTicks;
|
|
121
|
+
const voices = splitLanes.map((lane, laneIndex) => {
|
|
122
|
+
const notesInSpan = lane.filter(n => n.startTick >= span.startTick && n.startTick < spanEnd);
|
|
123
|
+
const voiceId = createId();
|
|
124
|
+
return {
|
|
125
|
+
id: voiceId,
|
|
126
|
+
name: `Voice ${laneIndex + 1}`,
|
|
127
|
+
events: fillVoiceGaps(notesInSpan, span.startTick, span.durationTicks, trackId, voiceId),
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
return {
|
|
131
|
+
id: createId(),
|
|
132
|
+
index: span.index,
|
|
133
|
+
startTick: span.startTick,
|
|
134
|
+
durationTicks: span.durationTicks,
|
|
135
|
+
timeSignature: span.timeSignature,
|
|
136
|
+
keySignature,
|
|
137
|
+
voices,
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=measures.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"measures.js","sourceRoot":"","sources":["../../src/midi/measures.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a score into a neutral tracker module.
|
|
3
|
+
*
|
|
4
|
+
* The exact inverse of `trackerToScore`, and it shares that file's grid:
|
|
5
|
+
* `rowsPerBeat` 4 means a row is a sixteenth, so a 4/4 bar is 16 rows and a
|
|
6
|
+
* 64-row pattern is four bars. Writing on the grid the importer reads is what
|
|
7
|
+
* makes a round trip comparable rather than approximate.
|
|
8
|
+
*
|
|
9
|
+
* **Everything lossy is counted, never hidden.** A tracker row grid, a channel
|
|
10
|
+
* count and a three-octave range are all narrower than a score, so an export
|
|
11
|
+
* can lose material — and a file that quietly is not the music is the failure
|
|
12
|
+
* mode this codebase designs against. The counts go in `TrackerFitReport` and
|
|
13
|
+
* the app shows them before writing.
|
|
14
|
+
*
|
|
15
|
+
* Pure: no bytes, no platform, no store. `TrackerCodec.encode` turns the result
|
|
16
|
+
* into a file.
|
|
17
|
+
*/
|
|
18
|
+
import type { Score, TrackerModule } from '@sudobility/music_types';
|
|
19
|
+
import { type WritableTrackerFormat } from './limits.js';
|
|
20
|
+
export type TrackerExportOptions = {
|
|
21
|
+
format: WritableTrackerFormat;
|
|
22
|
+
/** Rows per quarter note. 4 gives sixteenths and cannot express triplets. */
|
|
23
|
+
rowsPerBeat?: number;
|
|
24
|
+
};
|
|
25
|
+
export type TrackerFitReport = {
|
|
26
|
+
/** Notes moved into range. */
|
|
27
|
+
clampedNotes: number;
|
|
28
|
+
/** Voices that did not fit the format's channel count. */
|
|
29
|
+
droppedVoices: number;
|
|
30
|
+
/** Notes shorter than one row, lost to the grid. */
|
|
31
|
+
droppedShortNotes: number;
|
|
32
|
+
/** Notes whose start moved to land on a row. */
|
|
33
|
+
quantisedNotes: number;
|
|
34
|
+
};
|
|
35
|
+
export type TrackerExportResult = {
|
|
36
|
+
module: TrackerModule;
|
|
37
|
+
report: TrackerFitReport;
|
|
38
|
+
};
|
|
39
|
+
/** True when nothing was lost — the case that must not cost the user a click. */
|
|
40
|
+
export declare function isCleanFit(report: TrackerFitReport): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Speed and tempo for a musical BPM.
|
|
43
|
+
*
|
|
44
|
+
* `effectiveBpm(speed, tempo) = 6 * tempo / speed`, so at speed 6 the tempo
|
|
45
|
+
* field *is* the BPM. That only works while the result fits a byte, and a score
|
|
46
|
+
* may hold 20-400 BPM, so a slow score raises the speed and a fast one lowers
|
|
47
|
+
* it. Every score-legal BPM lands in 32-255 under one of the three.
|
|
48
|
+
*/
|
|
49
|
+
export declare function speedAndTempoFor(bpm: number): {
|
|
50
|
+
speed: number;
|
|
51
|
+
tempo: number;
|
|
52
|
+
};
|
|
53
|
+
export declare function scoreToTracker(score: Score, options: TrackerExportOptions): TrackerExportResult;
|
|
54
|
+
//# sourceMappingURL=export.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"export.d.ts","sourceRoot":"","sources":["../../src/mod/export.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,KAAK,EACV,KAAK,EAEL,aAAa,EACd,MAAM,yBAAyB,CAAC;AAGjC,OAAO,EAAkB,KAAK,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAYzE,MAAM,MAAM,oBAAoB,GAAG;IACjC,MAAM,EAAE,qBAAqB,CAAC;IAC9B,6EAA6E;IAC7E,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,8BAA8B;IAC9B,YAAY,EAAE,MAAM,CAAC;IACrB,0DAA0D;IAC1D,aAAa,EAAE,MAAM,CAAC;IACtB,oDAAoD;IACpD,iBAAiB,EAAE,MAAM,CAAC;IAC1B,gDAAgD;IAChD,cAAc,EAAE,MAAM,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,MAAM,EAAE,aAAa,CAAC;IACtB,MAAM,EAAE,gBAAgB,CAAC;CAC1B,CAAC;AAEF,iFAAiF;AACjF,wBAAgB,UAAU,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAO5D;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG;IAC7C,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf,CAUA;AAmBD,wBAAgB,cAAc,CAC5B,KAAK,EAAE,KAAK,EACZ,OAAO,EAAE,oBAAoB,GAC5B,mBAAmB,CAsIrB"}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { isNoteEvent } from '@sudobility/music_types';
|
|
2
|
+
import { pitchToMidi } from '@sudobility/music_types';
|
|
3
|
+
import { TRACKER_LIMITS } from './limits.js';
|
|
4
|
+
const DEFAULT_ROWS_PER_BEAT = 4;
|
|
5
|
+
const ROWS_PER_PATTERN = 64;
|
|
6
|
+
const DEFAULT_BPM = 125;
|
|
7
|
+
/** What a tracker's one-byte tempo field will accept. */
|
|
8
|
+
const MIN_TEMPO = 32;
|
|
9
|
+
const MAX_TEMPO = 255;
|
|
10
|
+
/** Tried in order; the first that puts tempo in range wins. */
|
|
11
|
+
const SPEED_CHOICES = [6, 12, 3];
|
|
12
|
+
/** True when nothing was lost — the case that must not cost the user a click. */
|
|
13
|
+
export function isCleanFit(report) {
|
|
14
|
+
return (report.clampedNotes === 0 &&
|
|
15
|
+
report.droppedVoices === 0 &&
|
|
16
|
+
report.droppedShortNotes === 0 &&
|
|
17
|
+
report.quantisedNotes === 0);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Speed and tempo for a musical BPM.
|
|
21
|
+
*
|
|
22
|
+
* `effectiveBpm(speed, tempo) = 6 * tempo / speed`, so at speed 6 the tempo
|
|
23
|
+
* field *is* the BPM. That only works while the result fits a byte, and a score
|
|
24
|
+
* may hold 20-400 BPM, so a slow score raises the speed and a fast one lowers
|
|
25
|
+
* it. Every score-legal BPM lands in 32-255 under one of the three.
|
|
26
|
+
*/
|
|
27
|
+
export function speedAndTempoFor(bpm) {
|
|
28
|
+
for (const speed of SPEED_CHOICES) {
|
|
29
|
+
const tempo = Math.round((bpm * speed) / 6);
|
|
30
|
+
if (tempo >= MIN_TEMPO && tempo <= MAX_TEMPO)
|
|
31
|
+
return { speed, tempo };
|
|
32
|
+
}
|
|
33
|
+
// Unreachable for a valid score; clamp rather than emit an illegal byte.
|
|
34
|
+
return {
|
|
35
|
+
speed: 6,
|
|
36
|
+
tempo: Math.min(MAX_TEMPO, Math.max(MIN_TEMPO, Math.round(bpm))),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const emptyCell = () => ({ instrument: 0, note: null });
|
|
40
|
+
/**
|
|
41
|
+
* Move a note into range by whole octaves.
|
|
42
|
+
*
|
|
43
|
+
* Octaves rather than a hard clamp to the boundary: an octave displacement is
|
|
44
|
+
* still the same pitch class, so a bassline pushed up an octave still reads as
|
|
45
|
+
* that bassline where a note pinned to the lowest available semitone does not.
|
|
46
|
+
*/
|
|
47
|
+
function clampToRange(midi, lowest, highest) {
|
|
48
|
+
let value = midi;
|
|
49
|
+
while (value < lowest)
|
|
50
|
+
value += 12;
|
|
51
|
+
while (value > highest)
|
|
52
|
+
value -= 12;
|
|
53
|
+
// A range narrower than an octave cannot satisfy both; prefer in-bounds.
|
|
54
|
+
return Math.min(highest, Math.max(lowest, value));
|
|
55
|
+
}
|
|
56
|
+
export function scoreToTracker(score, options) {
|
|
57
|
+
const limits = TRACKER_LIMITS[options.format];
|
|
58
|
+
const rowsPerBeat = options.rowsPerBeat ?? DEFAULT_ROWS_PER_BEAT;
|
|
59
|
+
const ticksPerRow = score.ppq / rowsPerBeat;
|
|
60
|
+
const report = {
|
|
61
|
+
clampedNotes: 0,
|
|
62
|
+
droppedVoices: 0,
|
|
63
|
+
droppedShortNotes: 0,
|
|
64
|
+
quantisedNotes: 0,
|
|
65
|
+
};
|
|
66
|
+
// Channel allocation: each track gets as many channels as its widest measure
|
|
67
|
+
// has voices, and they are laid out consecutively so a track's voices stay
|
|
68
|
+
// together. The total is capped by the format, and the overflow is counted.
|
|
69
|
+
let nextChannel = 0;
|
|
70
|
+
const channelOfVoice = [];
|
|
71
|
+
for (const track of score.tracks) {
|
|
72
|
+
const widest = track.measures.reduce((n, m) => Math.max(n, m.voices.length), 0);
|
|
73
|
+
const map = new Map();
|
|
74
|
+
for (let v = 0; v < widest; v += 1) {
|
|
75
|
+
if (nextChannel < limits.channels) {
|
|
76
|
+
map.set(v, nextChannel);
|
|
77
|
+
nextChannel += 1;
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
report.droppedVoices += 1;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
channelOfVoice.push(map);
|
|
84
|
+
}
|
|
85
|
+
const channels = Math.max(1, Math.min(limits.channels, nextChannel));
|
|
86
|
+
// How long the score runs, in rows.
|
|
87
|
+
let lastTick = 0;
|
|
88
|
+
for (const track of score.tracks) {
|
|
89
|
+
for (const measure of track.measures) {
|
|
90
|
+
lastTick = Math.max(lastTick, measure.startTick + measure.durationTicks);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const totalRows = Math.max(ROWS_PER_PATTERN, Math.ceil(lastTick / ticksPerRow));
|
|
94
|
+
const patternCount = Math.ceil(totalRows / ROWS_PER_PATTERN);
|
|
95
|
+
const patterns = Array.from({ length: patternCount }, () => Array.from({ length: ROWS_PER_PATTERN }, () => Array.from({ length: channels }, emptyCell)));
|
|
96
|
+
const cellAt = (row, channel) => {
|
|
97
|
+
const p = Math.floor(row / ROWS_PER_PATTERN);
|
|
98
|
+
if (p >= patterns.length)
|
|
99
|
+
return null;
|
|
100
|
+
return patterns[p][row % ROWS_PER_PATTERN][channel];
|
|
101
|
+
};
|
|
102
|
+
score.tracks.forEach((track, trackIndex) => {
|
|
103
|
+
const instrument = trackIndex + 1;
|
|
104
|
+
if (instrument > limits.instruments)
|
|
105
|
+
return;
|
|
106
|
+
const voiceChannels = channelOfVoice[trackIndex];
|
|
107
|
+
for (const measure of track.measures) {
|
|
108
|
+
measure.voices.forEach((voice, voiceIndex) => {
|
|
109
|
+
const channel = voiceChannels.get(voiceIndex);
|
|
110
|
+
if (channel === undefined)
|
|
111
|
+
return; // counted as a dropped voice already
|
|
112
|
+
for (const event of voice.events) {
|
|
113
|
+
if (!isNoteEvent(event))
|
|
114
|
+
continue;
|
|
115
|
+
const startRow = Math.round(event.startTick / ticksPerRow);
|
|
116
|
+
const endRow = Math.round((event.startTick + event.durationTicks) / ticksPerRow);
|
|
117
|
+
if (endRow <= startRow) {
|
|
118
|
+
report.droppedShortNotes += 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (event.startTick % ticksPerRow !== 0)
|
|
122
|
+
report.quantisedNotes += 1;
|
|
123
|
+
const sounding = pitchToMidi(event.pitch);
|
|
124
|
+
const midi = clampToRange(sounding, limits.lowestMidi, limits.highestMidi);
|
|
125
|
+
if (midi !== sounding)
|
|
126
|
+
report.clampedNotes += 1;
|
|
127
|
+
const start = cellAt(startRow, channel);
|
|
128
|
+
if (start) {
|
|
129
|
+
start.note = midi;
|
|
130
|
+
start.instrument = instrument;
|
|
131
|
+
}
|
|
132
|
+
// A release, unless something else already claims that row on this
|
|
133
|
+
// channel — a new note supersedes the release anyway.
|
|
134
|
+
const end = cellAt(endRow, channel);
|
|
135
|
+
if (end && end.note === null)
|
|
136
|
+
end.note = 'off';
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
// Tempo. The first entry sets both knobs in row 0; later ones move whichever
|
|
142
|
+
// the BPM needs, which `tempoChanges` on the import side reads back.
|
|
143
|
+
const tempoMap = score.tempoMap.length > 0
|
|
144
|
+
? score.tempoMap
|
|
145
|
+
: [{ tick: 0, bpm: DEFAULT_BPM }];
|
|
146
|
+
for (const entry of tempoMap) {
|
|
147
|
+
const row = Math.round(entry.tick / ticksPerRow);
|
|
148
|
+
const cell = cellAt(row, 0);
|
|
149
|
+
if (!cell)
|
|
150
|
+
continue;
|
|
151
|
+
const { speed, tempo } = speedAndTempoFor(entry.bpm);
|
|
152
|
+
cell.speed = speed;
|
|
153
|
+
cell.bpm = tempo;
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
module: {
|
|
157
|
+
format: options.format,
|
|
158
|
+
title: score.metadata.title,
|
|
159
|
+
channels,
|
|
160
|
+
instruments: score.tracks
|
|
161
|
+
.slice(0, limits.instruments)
|
|
162
|
+
.map((track, i) => ({
|
|
163
|
+
index: i + 1,
|
|
164
|
+
name: track.instrumentName || track.name,
|
|
165
|
+
})),
|
|
166
|
+
order: Array.from({ length: patternCount }, (_, i) => i),
|
|
167
|
+
patterns,
|
|
168
|
+
},
|
|
169
|
+
report,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=export.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"export.js","sourceRoot":"","sources":["../../src/mod/export.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { MusicalEvent, NoteEvent, UUID } from '@sudobility/music_types';
|
|
2
|
+
/**
|
|
3
|
+
* `events` with rests inserted before, between and after them, so the result
|
|
4
|
+
* tiles `[measureStartTick, measureStartTick + measureDurationTicks)` exactly.
|
|
5
|
+
*
|
|
6
|
+
* `events` must be sorted by `startTick` and must not overlap — which is what
|
|
7
|
+
* the caller's voice allocation already guarantees, since a note that overlaps
|
|
8
|
+
* one already placed goes to the next voice.
|
|
9
|
+
*/
|
|
10
|
+
export declare function fillVoiceWithRests(events: NoteEvent[], measureStartTick: number, measureDurationTicks: number, ppq: number, trackId: UUID, voiceId: UUID): MusicalEvent[];
|
|
11
|
+
//# sourceMappingURL=fill.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fill.d.ts","sourceRoot":"","sources":["../../src/mod/fill.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,yBAAyB,CAAC;AAE7E;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,SAAS,EAAE,EACnB,gBAAgB,EAAE,MAAM,EACxB,oBAAoB,EAAE,MAAM,EAC5B,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,IAAI,EACb,OAAO,EAAE,IAAI,GACZ,YAAY,EAAE,CA6BhB"}
|
package/dist/mod/fill.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filling a voice's gaps with rests.
|
|
3
|
+
*
|
|
4
|
+
* A voice must cover its measure exactly or the notation is wrong — a bar
|
|
5
|
+
* carrying 1200 ticks of a 1920-tick measure renders short. The module importer
|
|
6
|
+
* did not do this, which produced 554 `measure-underfull` warnings on a real
|
|
7
|
+
* module and bars that visibly did not add up.
|
|
8
|
+
*
|
|
9
|
+
* Pure arithmetic over an event list: no score model, no bytes, and every
|
|
10
|
+
* tracker format's importer gets it for free.
|
|
11
|
+
*/
|
|
12
|
+
import { decomposeDuration } from '@sudobility/music_types';
|
|
13
|
+
import { createId } from '@sudobility/music_types';
|
|
14
|
+
/**
|
|
15
|
+
* `events` with rests inserted before, between and after them, so the result
|
|
16
|
+
* tiles `[measureStartTick, measureStartTick + measureDurationTicks)` exactly.
|
|
17
|
+
*
|
|
18
|
+
* `events` must be sorted by `startTick` and must not overlap — which is what
|
|
19
|
+
* the caller's voice allocation already guarantees, since a note that overlaps
|
|
20
|
+
* one already placed goes to the next voice.
|
|
21
|
+
*/
|
|
22
|
+
export function fillVoiceWithRests(events, measureStartTick, measureDurationTicks, ppq, trackId, voiceId) {
|
|
23
|
+
const measureEnd = measureStartTick + measureDurationTicks;
|
|
24
|
+
const out = [];
|
|
25
|
+
let at = measureStartTick;
|
|
26
|
+
/** A gap may be longer than any single drawable value, so it can become several rests. */
|
|
27
|
+
const addRests = (from, to) => {
|
|
28
|
+
let cursor = from;
|
|
29
|
+
for (const ticks of decomposeDuration(to - from, ppq)) {
|
|
30
|
+
out.push({
|
|
31
|
+
id: createId(),
|
|
32
|
+
startTick: cursor,
|
|
33
|
+
durationTicks: ticks,
|
|
34
|
+
voiceId,
|
|
35
|
+
trackId,
|
|
36
|
+
});
|
|
37
|
+
cursor += ticks;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
for (const event of events) {
|
|
41
|
+
if (event.startTick > at)
|
|
42
|
+
addRests(at, event.startTick);
|
|
43
|
+
out.push({ ...event, voiceId, trackId });
|
|
44
|
+
at = event.startTick + event.durationTicks;
|
|
45
|
+
}
|
|
46
|
+
if (at < measureEnd)
|
|
47
|
+
addRests(at, measureEnd);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=fill.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"fill.js","sourceRoot":"","sources":["../../src/mod/fill.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAC5D,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAGnD;;;;;;;GAOG;AACH,MAAM,UAAU,kBAAkB,CAChC,MAAmB,EACnB,gBAAwB,EACxB,oBAA4B,EAC5B,GAAW,EACX,OAAa,EACb,OAAa;IAEb,MAAM,UAAU,GAAG,gBAAgB,GAAG,oBAAoB,CAAC;IAC3D,MAAM,GAAG,GAAmB,EAAE,CAAC;IAC/B,IAAI,EAAE,GAAG,gBAAgB,CAAC;IAE1B,0FAA0F;IAC1F,MAAM,QAAQ,GAAG,CAAC,IAAY,EAAE,EAAU,EAAQ,EAAE;QAClD,IAAI,MAAM,GAAG,IAAI,CAAC;QAClB,KAAK,MAAM,KAAK,IAAI,iBAAiB,CAAC,EAAE,GAAG,IAAI,EAAE,GAAG,CAAC,EAAE,CAAC;YACtD,GAAG,CAAC,IAAI,CAAC;gBACP,EAAE,EAAE,QAAQ,EAAE;gBACd,SAAS,EAAE,MAAM;gBACjB,aAAa,EAAE,KAAK;gBACpB,OAAO;gBACP,OAAO;aACR,CAAC,CAAC;YACH,MAAM,IAAI,KAAK,CAAC;QAClB,CAAC;IACH,CAAC,CAAC;IAEF,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,IAAI,KAAK,CAAC,SAAS,GAAG,EAAE;YAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC;QACxD,GAAG,CAAC,IAAI,CAAC,EAAE,GAAG,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,CAAC;QACzC,EAAE,GAAG,KAAK,CAAC,SAAS,GAAG,KAAK,CAAC,aAAa,CAAC;IAC7C,CAAC;IAED,IAAI,EAAE,GAAG,UAAU;QAAE,QAAQ,CAAC,EAAE,EAAE,UAAU,CAAC,CAAC;IAE9C,OAAO,GAAG,CAAC;AACb,CAAC"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { effectiveBpm, tempoChanges } from './timing.js';
|
|
2
|
+
import type { TrackerCell, TrackerModule } from './types.js';
|
|
3
|
+
import type { Score } from '@sudobility/music_types';
|
|
4
|
+
/**
|
|
5
|
+
* Rows in playback order, honouring `patternBreak`.
|
|
6
|
+
*
|
|
7
|
+
* `Dxx` ends a pattern where it appears rather than at row 64. Ignoring it —
|
|
8
|
+
* which this did — imports a module that uses breaks with too many bars, and
|
|
9
|
+
* silently.
|
|
10
|
+
*/
|
|
11
|
+
export declare function flattenRows(module: TrackerModule): TrackerCell[][];
|
|
12
|
+
export declare function trackerToScore(module: TrackerModule): Score;
|
|
13
|
+
export { effectiveBpm, tempoChanges };
|
|
14
|
+
//# sourceMappingURL=import.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"import.d.ts","sourceRoot":"","sources":["../../src/mod/import.ts"],"names":[],"mappings":"AAoBA,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAEzD,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAC7D,OAAO,KAAK,EAEV,KAAK,EAGN,MAAM,yBAAyB,CAAC;AAajC;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,aAAa,GAAG,WAAW,EAAE,EAAE,CAWlE;AAkDD,wBAAgB,cAAc,CAAC,MAAM,EAAE,aAAa,GAAG,KAAK,CAgH3D;AAED,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,CAAC"}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a parsed module into a score.
|
|
3
|
+
*
|
|
4
|
+
* Sits beside the MIDI importer, and for the same reason: the codec hands over
|
|
5
|
+
* raw data, and the mapping to the score model happens here where it is
|
|
6
|
+
* testable without the platform layer.
|
|
7
|
+
*
|
|
8
|
+
* Two decisions shape everything below:
|
|
9
|
+
*
|
|
10
|
+
* - **Notes group by sample, not by channel**, so all the bass lands on one
|
|
11
|
+
* track wherever it was played from. The consequence is that a track is not
|
|
12
|
+
* always a single line — two channels on one sample at the same row produce
|
|
13
|
+
* simultaneous notes, which go into separate **voices**.
|
|
14
|
+
* - **The order list is flattened.** A pattern played three times becomes
|
|
15
|
+
* three sets of measures; a score has no "repeat this pattern", and
|
|
16
|
+
* inventing one would trade an editable score for a faithful one.
|
|
17
|
+
*/
|
|
18
|
+
import { createEmptyScore } from '@sudobility/music_types';
|
|
19
|
+
import { createId } from '@sudobility/music_types';
|
|
20
|
+
import { midiToPitch } from '@sudobility/music_types';
|
|
21
|
+
import { effectiveBpm, tempoChanges } from './timing.js';
|
|
22
|
+
import { fillVoiceWithRests } from './fill.js';
|
|
23
|
+
/** A tracker row is a sixteenth: four to the beat. */
|
|
24
|
+
const ROWS_PER_BEAT = 4;
|
|
25
|
+
const BEATS_PER_MEASURE = 4;
|
|
26
|
+
/**
|
|
27
|
+
* Rows in playback order, honouring `patternBreak`.
|
|
28
|
+
*
|
|
29
|
+
* `Dxx` ends a pattern where it appears rather than at row 64. Ignoring it —
|
|
30
|
+
* which this did — imports a module that uses breaks with too many bars, and
|
|
31
|
+
* silently.
|
|
32
|
+
*/
|
|
33
|
+
export function flattenRows(module) {
|
|
34
|
+
const flat = [];
|
|
35
|
+
for (const patternIndex of module.order) {
|
|
36
|
+
const pattern = module.patterns[patternIndex];
|
|
37
|
+
if (!pattern)
|
|
38
|
+
continue;
|
|
39
|
+
for (const row of pattern) {
|
|
40
|
+
flat.push(row);
|
|
41
|
+
if (row.some(cell => cell.patternBreak))
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return flat;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Every note in playback order, with the row each ends on.
|
|
49
|
+
*
|
|
50
|
+
* A channel holds one note at a time. It ends when that channel plays another
|
|
51
|
+
* note, or on an explicit note-off — which MOD never sends, so there the first
|
|
52
|
+
* rule is the only one, exactly as it always was.
|
|
53
|
+
*/
|
|
54
|
+
function placeNotes(module) {
|
|
55
|
+
const flat = flattenRows(module);
|
|
56
|
+
const placed = [];
|
|
57
|
+
const open = new Map();
|
|
58
|
+
flat.forEach((cells, row) => {
|
|
59
|
+
cells.forEach((cell, channel) => {
|
|
60
|
+
if (cell.note === null)
|
|
61
|
+
return;
|
|
62
|
+
const previous = open.get(channel);
|
|
63
|
+
if (previous)
|
|
64
|
+
previous.endRow = row;
|
|
65
|
+
open.delete(channel);
|
|
66
|
+
// A release ends whatever was sounding and starts nothing.
|
|
67
|
+
if (cell.note === 'off')
|
|
68
|
+
return;
|
|
69
|
+
const note = {
|
|
70
|
+
instrument: cell.instrument,
|
|
71
|
+
startRow: row,
|
|
72
|
+
endRow: row + 1,
|
|
73
|
+
midi: cell.note,
|
|
74
|
+
};
|
|
75
|
+
open.set(channel, note);
|
|
76
|
+
placed.push(note);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
return placed;
|
|
80
|
+
}
|
|
81
|
+
/** Tempo events at ticks, from the flattened row sequence. */
|
|
82
|
+
function tempoMapOf(module, ticksPerRow) {
|
|
83
|
+
// Given the *flattened* sequence, not one pattern: a change in a later
|
|
84
|
+
// pattern would otherwise be invisible.
|
|
85
|
+
return tempoChanges(flattenRows(module)).map(change => ({
|
|
86
|
+
id: createId(),
|
|
87
|
+
tick: change.row * ticksPerRow,
|
|
88
|
+
bpm: change.bpm,
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
export function trackerToScore(module) {
|
|
92
|
+
const placed = placeNotes(module);
|
|
93
|
+
// One track per instrument actually used, in first-heard order so the result
|
|
94
|
+
// is stable rather than dependent on the instrument bank's layout.
|
|
95
|
+
const usedInstruments = [];
|
|
96
|
+
for (const note of placed) {
|
|
97
|
+
if (!usedInstruments.includes(note.instrument))
|
|
98
|
+
usedInstruments.push(note.instrument);
|
|
99
|
+
}
|
|
100
|
+
const nameOf = (instrument) => {
|
|
101
|
+
const found = module.instruments.find(s => s.index === instrument);
|
|
102
|
+
return found && found.name.length > 0
|
|
103
|
+
? found.name
|
|
104
|
+
: `Instrument ${instrument}`;
|
|
105
|
+
};
|
|
106
|
+
// Counted from the flattened rows, so a pattern break shortens the score
|
|
107
|
+
// rather than leaving empty bars at the end.
|
|
108
|
+
const totalRows = Math.max(1, flattenRows(module).length);
|
|
109
|
+
const measures = Math.max(1, Math.ceil(totalRows / (ROWS_PER_BEAT * BEATS_PER_MEASURE)));
|
|
110
|
+
const base = createEmptyScore({
|
|
111
|
+
title: module.title.length > 0 ? module.title : 'Module',
|
|
112
|
+
measures,
|
|
113
|
+
tracks: usedInstruments.length > 0
|
|
114
|
+
? usedInstruments.map(s => ({
|
|
115
|
+
name: nameOf(s),
|
|
116
|
+
instrumentName: nameOf(s),
|
|
117
|
+
clef: 'treble',
|
|
118
|
+
}))
|
|
119
|
+
: [
|
|
120
|
+
{
|
|
121
|
+
name: 'Module',
|
|
122
|
+
instrumentName: 'Module',
|
|
123
|
+
clef: 'treble',
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
const ticksPerRow = base.ppq / ROWS_PER_BEAT;
|
|
128
|
+
const tracks = base.tracks.map((track, trackIndex) => {
|
|
129
|
+
const instrument = usedInstruments[trackIndex];
|
|
130
|
+
const mine = placed.filter(n => n.instrument === instrument);
|
|
131
|
+
const withNotes = track.measures.map(measure => {
|
|
132
|
+
const measureEnd = measure.startTick + measure.durationTicks;
|
|
133
|
+
const here = mine.filter(n => {
|
|
134
|
+
const tick = n.startRow * ticksPerRow;
|
|
135
|
+
return tick >= measure.startTick && tick < measureEnd;
|
|
136
|
+
});
|
|
137
|
+
// An untouched measure already carries a full-measure rest from
|
|
138
|
+
// `createEmptyScore`, so it is already complete.
|
|
139
|
+
if (here.length === 0)
|
|
140
|
+
return measure;
|
|
141
|
+
// Voice allocation: a note overlapping one already placed in a voice
|
|
142
|
+
// goes to the next. This is what lets a sample-grouped track hold two
|
|
143
|
+
// channels sounding at once.
|
|
144
|
+
const voiceEnds = [];
|
|
145
|
+
const voiceEvents = [];
|
|
146
|
+
for (const note of here) {
|
|
147
|
+
const startTick = note.startRow * ticksPerRow;
|
|
148
|
+
const endTick = Math.min(note.endRow * ticksPerRow, measureEnd);
|
|
149
|
+
let voice = voiceEnds.findIndex(end => end <= startTick);
|
|
150
|
+
if (voice === -1) {
|
|
151
|
+
voice = voiceEnds.length;
|
|
152
|
+
voiceEnds.push(0);
|
|
153
|
+
voiceEvents.push([]);
|
|
154
|
+
}
|
|
155
|
+
voiceEnds[voice] = endTick;
|
|
156
|
+
voiceEvents[voice].push({
|
|
157
|
+
id: createId(),
|
|
158
|
+
pitch: midiToPitch(note.midi, measure.keySignature),
|
|
159
|
+
startTick,
|
|
160
|
+
durationTicks: Math.max(1, endTick - startTick),
|
|
161
|
+
velocity: 80,
|
|
162
|
+
voiceId: '',
|
|
163
|
+
trackId: track.id,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const voices = voiceEvents.map((events, i) => {
|
|
167
|
+
const id = measure.voices[i]?.id ?? createId();
|
|
168
|
+
return {
|
|
169
|
+
id,
|
|
170
|
+
name: `Voice ${i + 1}`,
|
|
171
|
+
// Rests around the notes, or the bar does not add up — see `fill.ts`.
|
|
172
|
+
events: fillVoiceWithRests(events, measure.startTick, measure.durationTicks, base.ppq, track.id, id),
|
|
173
|
+
};
|
|
174
|
+
});
|
|
175
|
+
return { ...measure, voices };
|
|
176
|
+
});
|
|
177
|
+
return { ...track, measures: withNotes };
|
|
178
|
+
});
|
|
179
|
+
return { ...base, tracks, tempoMap: tempoMapOf(module, ticksPerRow) };
|
|
180
|
+
}
|
|
181
|
+
export { effectiveBpm, tempoChanges };
|
|
182
|
+
//# sourceMappingURL=import.js.map
|