@hum2music/midi 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +17 -4
- package/src/index.ts +2 -0
- package/src/note-event-adapter.ts +21 -0
- package/src/smf-writer.ts +195 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hum2music/midi",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Standard MIDI File reading/writing (single and multi-track, pitch bend) for Hum2Music.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"homepage": "https://github.com/hum2music/hum2music",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/hum2music/hum2music.git",
|
|
10
|
+
"directory": "packages/midi"
|
|
11
|
+
},
|
|
12
|
+
"type": "module",
|
|
13
|
+
"main": "./src/index.ts",
|
|
14
|
+
"types": "./src/index.ts",
|
|
15
|
+
"files": ["src"],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@hum2music/core": "0.1.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { NoteEvent } from "@hum2music/core";
|
|
2
|
+
import type { MidiNoteEvent } from "./smf-writer";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Converts beat-relative `NoteEvent`s (as produced by quantization/
|
|
6
|
+
* humanization) into absolute-ms `MidiNoteEvent`s ready for
|
|
7
|
+
* `writeStandardMidiFile`. `expression.timingOffsetMs` is added on top of
|
|
8
|
+
* the grid position rather than discarded, and `expression.pitchCurve`
|
|
9
|
+
* passes through for pitch-bend export.
|
|
10
|
+
*/
|
|
11
|
+
export function noteEventsToMidiNotes(notes: NoteEvent[], bpm: number): MidiNoteEvent[] {
|
|
12
|
+
const msPerBeat = 60000 / bpm;
|
|
13
|
+
|
|
14
|
+
return notes.map((note) => ({
|
|
15
|
+
pitch: note.pitch,
|
|
16
|
+
startMs: note.startBeat * msPerBeat + (note.expression?.timingOffsetMs ?? 0),
|
|
17
|
+
durationMs: note.durationBeats * msPerBeat,
|
|
18
|
+
velocity: note.velocity,
|
|
19
|
+
pitchCurve: note.expression?.pitchCurve,
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
export interface MidiPitchCurveSample {
|
|
2
|
+
offsetMs: number;
|
|
3
|
+
cents: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface MidiNoteEvent {
|
|
7
|
+
pitch: number;
|
|
8
|
+
startMs: number;
|
|
9
|
+
durationMs: number;
|
|
10
|
+
velocity: number;
|
|
11
|
+
pitchCurve?: MidiPitchCurveSample[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface WriteSmfOptions {
|
|
15
|
+
ppq?: number;
|
|
16
|
+
bpm?: number;
|
|
17
|
+
channel?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_PPQ = 480;
|
|
21
|
+
const DEFAULT_BPM = 120;
|
|
22
|
+
|
|
23
|
+
export function writeStandardMidiFile(notes: MidiNoteEvent[], options: WriteSmfOptions = {}): Uint8Array {
|
|
24
|
+
const ppq = options.ppq ?? DEFAULT_PPQ;
|
|
25
|
+
const bpm = options.bpm ?? DEFAULT_BPM;
|
|
26
|
+
const channel = options.channel ?? 0;
|
|
27
|
+
const microsecondsPerQuarter = Math.round(60_000_000 / bpm);
|
|
28
|
+
|
|
29
|
+
const header = buildHeaderChunk(1, 2, ppq);
|
|
30
|
+
const tempoTrack = buildTempoTrack(microsecondsPerQuarter);
|
|
31
|
+
const melodyTrack = buildNoteTrack(notes, ppq, microsecondsPerQuarter, channel);
|
|
32
|
+
|
|
33
|
+
return concatBytes([header, tempoTrack, melodyTrack]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface MidiTrack {
|
|
37
|
+
notes: MidiNoteEvent[];
|
|
38
|
+
/** MIDI channel (0-15). Defaults to the track's index; use 9 for GM percussion. */
|
|
39
|
+
channel?: number;
|
|
40
|
+
/** General MIDI program number (0-127). Omit for channel 9 (percussion uses the GM drum map instead). */
|
|
41
|
+
programNumber?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Writes one tempo/metadata track plus one note track per entry in
|
|
46
|
+
* `tracks`, in order (e.g. melody, piano, bass, drums per PRD §13.1).
|
|
47
|
+
*/
|
|
48
|
+
export function writeMultiTrackMidiFile(tracks: MidiTrack[], options: WriteSmfOptions = {}): Uint8Array {
|
|
49
|
+
const ppq = options.ppq ?? DEFAULT_PPQ;
|
|
50
|
+
const bpm = options.bpm ?? DEFAULT_BPM;
|
|
51
|
+
const microsecondsPerQuarter = Math.round(60_000_000 / bpm);
|
|
52
|
+
|
|
53
|
+
const header = buildHeaderChunk(1, 1 + tracks.length, ppq);
|
|
54
|
+
const tempoTrack = buildTempoTrack(microsecondsPerQuarter);
|
|
55
|
+
const noteTracks = tracks.map((track, index) =>
|
|
56
|
+
buildNoteTrack(track.notes, ppq, microsecondsPerQuarter, track.channel ?? index, track.programNumber),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return concatBytes([header, tempoTrack, ...noteTracks]);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildHeaderChunk(format: number, numTracks: number, ppq: number): Uint8Array {
|
|
63
|
+
const chunk = new Uint8Array(14);
|
|
64
|
+
const view = new DataView(chunk.buffer);
|
|
65
|
+
chunk.set([0x4d, 0x54, 0x68, 0x64]);
|
|
66
|
+
view.setUint32(4, 6, false);
|
|
67
|
+
view.setUint16(8, format, false);
|
|
68
|
+
view.setUint16(10, numTracks, false);
|
|
69
|
+
view.setUint16(12, ppq, false);
|
|
70
|
+
return chunk;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildTempoTrack(microsecondsPerQuarter: number): Uint8Array {
|
|
74
|
+
const tempoEvent = [
|
|
75
|
+
0x00,
|
|
76
|
+
0xff,
|
|
77
|
+
0x51,
|
|
78
|
+
0x03,
|
|
79
|
+
(microsecondsPerQuarter >> 16) & 0xff,
|
|
80
|
+
(microsecondsPerQuarter >> 8) & 0xff,
|
|
81
|
+
microsecondsPerQuarter & 0xff,
|
|
82
|
+
];
|
|
83
|
+
const timeSignatureEvent = [0x00, 0xff, 0x58, 0x04, 4, 2, 24, 8];
|
|
84
|
+
const endOfTrack = [0x00, 0xff, 0x2f, 0x00];
|
|
85
|
+
return wrapTrackChunk(new Uint8Array([...tempoEvent, ...timeSignatureEvent, ...endOfTrack]));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildNoteTrack(
|
|
89
|
+
notes: MidiNoteEvent[],
|
|
90
|
+
ppq: number,
|
|
91
|
+
microsecondsPerQuarter: number,
|
|
92
|
+
channel: number,
|
|
93
|
+
programNumber?: number,
|
|
94
|
+
): Uint8Array {
|
|
95
|
+
const msPerQuarter = microsecondsPerQuarter / 1000;
|
|
96
|
+
const ticksPerMs = ppq / msPerQuarter;
|
|
97
|
+
|
|
98
|
+
type TimedEvent = { tick: number; order: number; bytes: number[] };
|
|
99
|
+
const events: TimedEvent[] = [];
|
|
100
|
+
let order = 0;
|
|
101
|
+
|
|
102
|
+
if (programNumber !== undefined) {
|
|
103
|
+
events.push({ tick: 0, order: order++, bytes: [0xc0 | channel, programNumber & 0x7f] });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const note of notes) {
|
|
107
|
+
// A note can't sound before the start of the file (e.g. humanization
|
|
108
|
+
// jitter nudging the very first note earlier than tick 0); clamp it.
|
|
109
|
+
const startTick = Math.max(0, Math.round(note.startMs * ticksPerMs));
|
|
110
|
+
const endTick = Math.max(startTick + 1, Math.round((note.startMs + note.durationMs) * ticksPerMs));
|
|
111
|
+
|
|
112
|
+
events.push({ tick: startTick, order: order++, bytes: [0x90 | channel, note.pitch, clampVelocity(note.velocity)] });
|
|
113
|
+
|
|
114
|
+
for (const sample of note.pitchCurve ?? []) {
|
|
115
|
+
const sampleTick = clampTick(Math.round((note.startMs + sample.offsetMs) * ticksPerMs), startTick, endTick - 1);
|
|
116
|
+
events.push({ tick: sampleTick, order: order++, bytes: buildPitchBendBytes(channel, sample.cents) });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
events.push({ tick: endTick, order: order++, bytes: [0x80 | channel, note.pitch, 0] });
|
|
120
|
+
|
|
121
|
+
if (note.pitchCurve && note.pitchCurve.length > 0) {
|
|
122
|
+
events.push({ tick: endTick, order: order++, bytes: buildPitchBendBytes(channel, 0) });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
events.sort((a, b) => a.tick - b.tick || a.order - b.order);
|
|
127
|
+
|
|
128
|
+
const chunk: number[] = [];
|
|
129
|
+
let lastTick = 0;
|
|
130
|
+
for (const event of events) {
|
|
131
|
+
chunk.push(...encodeVariableLengthQuantity(event.tick - lastTick));
|
|
132
|
+
chunk.push(...event.bytes);
|
|
133
|
+
lastTick = event.tick;
|
|
134
|
+
}
|
|
135
|
+
chunk.push(0x00, 0xff, 0x2f, 0x00);
|
|
136
|
+
|
|
137
|
+
return wrapTrackChunk(new Uint8Array(chunk));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function wrapTrackChunk(data: Uint8Array): Uint8Array {
|
|
141
|
+
const header = new Uint8Array(8);
|
|
142
|
+
const view = new DataView(header.buffer);
|
|
143
|
+
header.set([0x4d, 0x54, 0x72, 0x6b]);
|
|
144
|
+
view.setUint32(4, data.length, false);
|
|
145
|
+
return concatBytes([header, data]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function encodeVariableLengthQuantity(value: number): number[] {
|
|
149
|
+
if (value < 0) throw new Error("MIDI delta time cannot be negative");
|
|
150
|
+
|
|
151
|
+
let buffer = value & 0x7f;
|
|
152
|
+
let remaining = value >> 7;
|
|
153
|
+
while (remaining > 0) {
|
|
154
|
+
buffer <<= 8;
|
|
155
|
+
buffer |= 0x80 | (remaining & 0x7f);
|
|
156
|
+
remaining >>= 7;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const bytes: number[] = [];
|
|
160
|
+
while (true) {
|
|
161
|
+
bytes.push(buffer & 0xff);
|
|
162
|
+
if (buffer & 0x80) {
|
|
163
|
+
buffer >>= 8;
|
|
164
|
+
} else {
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return bytes;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function clampVelocity(velocity: number): number {
|
|
172
|
+
return Math.min(127, Math.max(1, Math.round(velocity)));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function clampTick(tick: number, min: number, max: number): number {
|
|
176
|
+
return Math.min(max, Math.max(min, tick));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Pitch bend is a 14-bit value (0-16383, center 8192); the default bend range on most synths is +/-2 semitones (200 cents). */
|
|
180
|
+
function buildPitchBendBytes(channel: number, cents: number, bendRangeCents = 200): number[] {
|
|
181
|
+
const normalized = Math.min(1, Math.max(-1, cents / bendRangeCents));
|
|
182
|
+
const value = Math.min(16383, Math.max(0, Math.round(8192 + normalized * 8192)));
|
|
183
|
+
return [0xe0 | channel, value & 0x7f, (value >> 7) & 0x7f];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function concatBytes(chunks: Uint8Array[]): Uint8Array {
|
|
187
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
188
|
+
const result = new Uint8Array(total);
|
|
189
|
+
let offset = 0;
|
|
190
|
+
for (const chunk of chunks) {
|
|
191
|
+
result.set(chunk, offset);
|
|
192
|
+
offset += chunk.length;
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
}
|
package/README.md
DELETED