@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.
Files changed (81) hide show
  1. package/dist/index.d.ts +43 -0
  2. package/dist/index.d.ts.map +1 -0
  3. package/dist/index.js +46 -0
  4. package/dist/index.js.map +1 -0
  5. package/dist/midi/analyze.d.ts +53 -0
  6. package/dist/midi/analyze.d.ts.map +1 -0
  7. package/dist/midi/analyze.js +58 -0
  8. package/dist/midi/analyze.js.map +1 -0
  9. package/dist/midi/export.d.ts +20 -0
  10. package/dist/midi/export.d.ts.map +1 -0
  11. package/dist/midi/export.js +135 -0
  12. package/dist/midi/export.js.map +1 -0
  13. package/dist/midi/grid-detection.d.ts +41 -0
  14. package/dist/midi/grid-detection.d.ts.map +1 -0
  15. package/dist/midi/grid-detection.js +61 -0
  16. package/dist/midi/grid-detection.js.map +1 -0
  17. package/dist/midi/import-options.d.ts +45 -0
  18. package/dist/midi/import-options.d.ts.map +1 -0
  19. package/dist/midi/import-options.js +38 -0
  20. package/dist/midi/import-options.js.map +1 -0
  21. package/dist/midi/import.d.ts +43 -0
  22. package/dist/midi/import.d.ts.map +1 -0
  23. package/dist/midi/import.js +441 -0
  24. package/dist/midi/import.js.map +1 -0
  25. package/dist/midi/key-detection.d.ts +11 -0
  26. package/dist/midi/key-detection.d.ts.map +1 -0
  27. package/dist/midi/key-detection.js +96 -0
  28. package/dist/midi/key-detection.js.map +1 -0
  29. package/dist/midi/measures.d.ts +39 -0
  30. package/dist/midi/measures.d.ts.map +1 -0
  31. package/dist/midi/measures.js +141 -0
  32. package/dist/midi/measures.js.map +1 -0
  33. package/dist/mod/export.d.ts +54 -0
  34. package/dist/mod/export.d.ts.map +1 -0
  35. package/dist/mod/export.js +172 -0
  36. package/dist/mod/export.js.map +1 -0
  37. package/dist/mod/fill.d.ts +11 -0
  38. package/dist/mod/fill.d.ts.map +1 -0
  39. package/dist/mod/fill.js +50 -0
  40. package/dist/mod/fill.js.map +1 -0
  41. package/dist/mod/import.d.ts +14 -0
  42. package/dist/mod/import.d.ts.map +1 -0
  43. package/dist/mod/import.js +182 -0
  44. package/dist/mod/import.js.map +1 -0
  45. package/dist/mod/limits.d.ts +26 -0
  46. package/dist/mod/limits.d.ts.map +1 -0
  47. package/dist/mod/limits.js +14 -0
  48. package/dist/mod/limits.js.map +1 -0
  49. package/dist/mod/timing.d.ts +29 -0
  50. package/dist/mod/timing.d.ts.map +1 -0
  51. package/dist/mod/timing.js +61 -0
  52. package/dist/mod/timing.js.map +1 -0
  53. package/dist/mod/types.d.ts +8 -0
  54. package/dist/mod/types.d.ts.map +1 -0
  55. package/dist/mod/types.js +2 -0
  56. package/dist/mod/types.js.map +1 -0
  57. package/dist/musicxml/duration-map.d.ts +34 -0
  58. package/dist/musicxml/duration-map.d.ts.map +1 -0
  59. package/dist/musicxml/duration-map.js +79 -0
  60. package/dist/musicxml/duration-map.js.map +1 -0
  61. package/dist/musicxml/export.d.ts +35 -0
  62. package/dist/musicxml/export.d.ts.map +1 -0
  63. package/dist/musicxml/export.js +271 -0
  64. package/dist/musicxml/export.js.map +1 -0
  65. package/dist/musicxml/import.d.ts +52 -0
  66. package/dist/musicxml/import.d.ts.map +1 -0
  67. package/dist/musicxml/import.js +637 -0
  68. package/dist/musicxml/import.js.map +1 -0
  69. package/dist/test/fixtures.d.ts +45 -0
  70. package/dist/test/fixtures.d.ts.map +1 -0
  71. package/dist/test/fixtures.js +420 -0
  72. package/dist/test/fixtures.js.map +1 -0
  73. package/dist/test/musicxml-warnings.d.ts +11 -0
  74. package/dist/test/musicxml-warnings.d.ts.map +1 -0
  75. package/dist/test/musicxml-warnings.js +26 -0
  76. package/dist/test/musicxml-warnings.js.map +1 -0
  77. package/dist/test/platform.d.ts +13 -0
  78. package/dist/test/platform.d.ts.map +1 -0
  79. package/dist/test/platform.js +191 -0
  80. package/dist/test/platform.js.map +1 -0
  81. 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":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AACnD,OAAO,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AAQlE,OAAO,EAAE,oBAAoB,EAAE,MAAM,yBAAyB,CAAC;AAE/D,MAAM,CAAC,MAAM,sBAAsB,GAAkB;IACnD,SAAS,EAAE,CAAC;IACZ,WAAW,EAAE,CAAC;CACf,CAAC;AAcF;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,iBAAiB,CAC/B,OAA8B,EAC9B,GAAW,EACX,OAAe;IAEf,MAAM,MAAM,GAAG,CAAC,GAAG,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC,CAAC;IAC5D,MAAM,UAAU,GACd,MAAM,CAAC,MAAM,GAAG,CAAC,IAAI,MAAM,CAAC,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC;QACvC,CAAC,CAAC,MAAM;QACR,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,aAAa,EAAE,sBAAsB,EAAE,EAAE,GAAG,MAAM,CAAC,CAAC;IAEtE,MAAM,KAAK,GAAkB,EAAE,CAAC;IAChC,IAAI,MAAM,GAAG,CAAC,CAAC;IACf,IAAI,KAAK,GAAG,CAAC,CAAC;IAEd,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,UAAU,CAAC,MAAM,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC;QAC9C,MAAM,aAAa,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC,aAAa,CAAC;QAClD,MAAM,YAAY,GAAG,oBAAoB,CAAC,aAAa,EAAE,GAAG,CAAC,CAAC;QAC9D,IAAI,YAAY,IAAI,CAAC;YAAE,SAAS;QAEhC,MAAM,aAAa,GAAG,CAAC,KAAK,UAAU,CAAC,MAAM,GAAG,CAAC,CAAC;QAClD,MAAM,UAAU,GAAG,aAAa;YAC9B,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,MAAM,GAAG,YAAY,CAAC;YAC1C,CAAC,CAAC,UAAU,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC;QAE3B,OAAO,MAAM,GAAG,UAAU,EAAE,CAAC;YAC3B,KAAK,CAAC,IAAI,CAAC;gBACT,KAAK;gBACL,SAAS,EAAE,MAAM;gBACjB,aAAa,EAAE,YAAY;gBAC3B,aAAa;aACd,CAAC,CAAC;YACH,MAAM,IAAI,YAAY,CAAC;YACvB,KAAK,IAAI,CAAC,CAAC;QACb,CAAC;IACH,CAAC;IAED,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACvB,MAAM,YAAY,GAAG,oBAAoB,CAAC,sBAAsB,EAAE,GAAG,CAAC,CAAC;QACvE,KAAK,CAAC,IAAI,CAAC;YACT,KAAK,EAAE,CAAC;YACR,SAAS,EAAE,CAAC;YACZ,aAAa,EAAE,YAAY;YAC3B,aAAa,EAAE,sBAAsB;SACtC,CAAC,CAAC;IACL,CAAC;IAED,OAAO,KAAK,CAAC;AACf,CAAC;AAED;;;;;;GAMG;AACH,SAAS,aAAa,CACpB,KAAkB,EAClB,SAAiB,EACjB,aAAqB,EACrB,OAAe,EACf,OAAe;IAEf,MAAM,MAAM,GAAG,CAAC,GAAG,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,SAAS,GAAG,CAAC,CAAC,SAAS,CAAC,CAAC;IACpE,MAAM,MAAM,GAAmB,EAAE,CAAC;IAClC,MAAM,OAAO,GAAG,SAAS,GAAG,aAAa,CAAC;IAC1C,IAAI,MAAM,GAAG,SAAS,CAAC;IAEvB,KAAK,MAAM,IAAI,IAAI,MAAM,EAAE,CAAC;QAC1B,IAAI,IAAI,CAAC,SAAS,GAAG,MAAM,EAAE,CAAC;YAC5B,MAAM,CAAC,IAAI,CAAC;gBACV,EAAE,EAAE,QAAQ,EAAE;gBACd,SAAS,EAAE,MAAM;gBACjB,aAAa,EAAE,IAAI,CAAC,SAAS,GAAG,MAAM;gBACtC,OAAO;gBACP,OAAO;aACR,CAAC,CAAC;QACL,CAAC;QACD,MAAM,CAAC,IAAI,CAAC,EAAE,GAAG,IAAI,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,CAAC;QAC3C,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,aAAa,CAAC,CAAC;IACjE,CAAC;IAED,IAAI,MAAM,GAAG,OAAO,EAAE,CAAC;QACrB,MAAM,CAAC,IAAI,CAAC;YACV,EAAE,EAAE,QAAQ,EAAE;YACd,SAAS,EAAE,MAAM;YACjB,aAAa,EAAE,OAAO,GAAG,MAAM;YAC/B,OAAO;YACP,OAAO;SACR,CAAC,CAAC;IACL,CAAC;IAED,OAAO,MAAM,CAAC;AAChB,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,qBAAqB,CACnC,UAAyB,EACzB,KAAoB,EACpB,YAA0B,EAC1B,OAAe;IAEf,MAAM,UAAU,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC;IAC/C,MAAM,KAAK,GAAG,UAAU,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;IACxD,MAAM,UAAU,GAAG,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,CAClC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC,uBAAuB,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC,CAChE,CAAC;IAEF,OAAO,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE;QACtB,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,aAAa,CAAC;QACpD,MAAM,MAAM,GAAG,UAAU,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,SAAS,EAAE,EAAE;YAChD,MAAM,WAAW,GAAG,IAAI,CAAC,MAAM,CAC7B,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS,IAAI,IAAI,CAAC,SAAS,IAAI,CAAC,CAAC,SAAS,GAAG,OAAO,CAC5D,CAAC;YACF,MAAM,OAAO,GAAG,QAAQ,EAAE,CAAC;YAC3B,OAAO;gBACL,EAAE,EAAE,OAAO;gBACX,IAAI,EAAE,SAAS,SAAS,GAAG,CAAC,EAAE;gBAC9B,MAAM,EAAE,aAAa,CACnB,WAAW,EACX,IAAI,CAAC,SAAS,EACd,IAAI,CAAC,aAAa,EAClB,OAAO,EACP,OAAO,CACR;aACF,CAAC;QACJ,CAAC,CAAC,CAAC;QAEH,OAAO;YACL,EAAE,EAAE,QAAQ,EAAE;YACd,KAAK,EAAE,IAAI,CAAC,KAAK;YACjB,SAAS,EAAE,IAAI,CAAC,SAAS;YACzB,aAAa,EAAE,IAAI,CAAC,aAAa;YACjC,aAAa,EAAE,IAAI,CAAC,aAAa;YACjC,YAAY;YACZ,MAAM;SACP,CAAC;IACJ,CAAC,CAAC,CAAC;AACL,CAAC"}
@@ -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"}
@@ -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