midiwire 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.
@@ -0,0 +1,244 @@
1
+ import { MIDIValidationError } from "../core/errors.js"
2
+
3
+ /**
4
+ * Clamp a value between min and max
5
+ * @param {number} value - Value to clamp
6
+ * @param {number} min - Minimum value
7
+ * @param {number} max - Maximum value
8
+ * @returns {number}
9
+ */
10
+ export function clamp(value, min, max) {
11
+ return Math.max(min, Math.min(max, value))
12
+ }
13
+
14
+ /**
15
+ * Normalize a value from input range to MIDI range (0-127)
16
+ * @param {number} value - Input value
17
+ * @param {number} inputMin - Input minimum
18
+ * @param {number} inputMax - Input maximum
19
+ * @param {boolean} [invert=false] - Invert the output
20
+ * @returns {number} MIDI value (0-127)
21
+ */
22
+ export function normalizeValue(value, inputMin, inputMax, invert = false) {
23
+ // Normalize to 0-1
24
+ const normalized = (value - inputMin) / (inputMax - inputMin)
25
+
26
+ // Invert if requested
27
+ const final = invert ? 1 - normalized : normalized
28
+
29
+ // Scale to MIDI range (0-127)
30
+ const midiValue = final * 127
31
+
32
+ return clamp(Math.round(midiValue), 0, 127)
33
+ }
34
+
35
+ /**
36
+ * Denormalize a MIDI value (0-127) to a custom range
37
+ * @param {number} midiValue - MIDI value (0-127)
38
+ * @param {number} outputMin - Output minimum
39
+ * @param {number} outputMax - Output maximum
40
+ * @param {boolean} [invert=false] - Invert the input
41
+ * @returns {number}
42
+ */
43
+ export function denormalizeValue(midiValue, outputMin, outputMax, invert = false) {
44
+ // Normalize MIDI value to 0-1
45
+ let normalized = clamp(midiValue, 0, 127) / 127
46
+
47
+ // Invert if requested
48
+ if (invert) {
49
+ normalized = 1 - normalized
50
+ }
51
+
52
+ // Scale to output range
53
+ return outputMin + normalized * (outputMax - outputMin)
54
+ }
55
+
56
+ /**
57
+ * Convert a note name to MIDI note number
58
+ * @param {string} noteName - Note name (e.g., "C4", "A#3", "Bb5")
59
+ * @returns {number} MIDI note number (0-127)
60
+ * @throws {MIDIValidationError} If noteName is not a valid note format
61
+ */
62
+ export function noteNameToNumber(noteName) {
63
+ const notes = {
64
+ C: 0,
65
+ "C#": 1,
66
+ DB: 1,
67
+ D: 2,
68
+ "D#": 3,
69
+ EB: 3,
70
+ E: 4,
71
+ F: 5,
72
+ "F#": 6,
73
+ GB: 6,
74
+ G: 7,
75
+ "G#": 8,
76
+ AB: 8,
77
+ A: 9,
78
+ "A#": 10,
79
+ BB: 10,
80
+ B: 11,
81
+ }
82
+
83
+ const match = noteName.match(/^([A-G][#b]?)(-?\d+)$/i)
84
+ if (!match) {
85
+ throw new MIDIValidationError(`Invalid note name: ${noteName}`, "note", noteName)
86
+ }
87
+
88
+ const [, note, octave] = match
89
+ const noteValue = notes[note.toUpperCase()]
90
+
91
+ if (noteValue === undefined) {
92
+ throw new MIDIValidationError(`Invalid note: ${note}`, "note", note)
93
+ }
94
+
95
+ const midiNote = (parseInt(octave, 10) + 1) * 12 + noteValue
96
+ return clamp(midiNote, 0, 127)
97
+ }
98
+
99
+ /**
100
+ * Convert MIDI note number to note name
101
+ * @param {number} noteNumber - MIDI note number (0-127)
102
+ * @param {boolean} [useFlats=false] - Use flats instead of sharps
103
+ * @returns {string} Note name (e.g., "C4")
104
+ */
105
+ export function noteNumberToName(noteNumber, useFlats = false) {
106
+ const sharps = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
107
+ const flats = ["C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B"]
108
+
109
+ const notes = useFlats ? flats : sharps
110
+ const octave = Math.floor(noteNumber / 12) - 1
111
+ const note = notes[noteNumber % 12]
112
+
113
+ return `${note}${octave}`
114
+ }
115
+
116
+ /**
117
+ * Convert frequency (Hz) to nearest MIDI note number
118
+ * @param {number} frequency - Frequency in Hz
119
+ * @returns {number} MIDI note number
120
+ */
121
+ export function frequencyToNote(frequency) {
122
+ const noteNumber = 69 + 12 * Math.log2(frequency / 440)
123
+ return clamp(Math.round(noteNumber), 0, 127)
124
+ }
125
+
126
+ /**
127
+ * Convert MIDI note number to frequency (Hz)
128
+ * @param {number} noteNumber - MIDI note number
129
+ * @returns {number} Frequency in Hz
130
+ */
131
+ export function noteToFrequency(noteNumber) {
132
+ return 440 * 2 ** ((noteNumber - 69) / 12)
133
+ }
134
+
135
+ /**
136
+ * Get CC name for common controller numbers
137
+ * @param {number} cc - CC number
138
+ * @returns {string} CC name or "CC {number}"
139
+ */
140
+ export function getCCName(cc) {
141
+ const names = {
142
+ 0: "Bank Select",
143
+ 1: "Modulation",
144
+ 2: "Breath Controller",
145
+ 4: "Foot Controller",
146
+ 5: "Portamento Time",
147
+ 7: "Volume",
148
+ 8: "Balance",
149
+ 10: "Pan",
150
+ 11: "Expression",
151
+ 64: "Sustain Pedal",
152
+ 65: "Portamento",
153
+ 66: "Sostenuto",
154
+ 67: "Soft Pedal",
155
+ 68: "Legato",
156
+ 71: "Resonance",
157
+ 72: "Release Time",
158
+ 73: "Attack Time",
159
+ 74: "Cutoff",
160
+ 75: "Decay Time",
161
+ 76: "Vibrato Rate",
162
+ 77: "Vibrato Depth",
163
+ 78: "Vibrato Delay",
164
+ 84: "Portamento Control",
165
+ 91: "Reverb",
166
+ 92: "Tremolo",
167
+ 93: "Chorus",
168
+ 94: "Detune",
169
+ 95: "Phaser",
170
+ 120: "All Sound Off",
171
+ 121: "Reset All Controllers",
172
+ 123: "All Notes Off",
173
+ }
174
+
175
+ return names[cc] || `CC ${cc}`
176
+ }
177
+
178
+ /**
179
+ * Encode a 14-bit value into MSB and LSB (7-bit each)
180
+ * @param {number} value - 14-bit value (0-16383)
181
+ * @returns {{msb: number, lsb: number}} MSB and LSB values
182
+ */
183
+ export function encode14BitValue(value) {
184
+ const clamped = clamp(Math.round(value), 0, 16383)
185
+ return {
186
+ msb: (clamped >> 7) & 0x7f,
187
+ lsb: clamped & 0x7f,
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Decode MSB and LSB (7-bit each) into a 14-bit value
193
+ * @param {number} msb - Most significant byte (0-127)
194
+ * @param {number} lsb - Least significant byte (0-127)
195
+ * @returns {number} 14-bit value (0-16383)
196
+ */
197
+ export function decode14BitValue(msb, lsb) {
198
+ return (clamp(msb, 0, 127) << 7) | clamp(lsb, 0, 127)
199
+ }
200
+
201
+ /**
202
+ * Normalize a 14-bit value from input range
203
+ * @param {number} value - Input value
204
+ * @param {number} inputMin - Input minimum
205
+ * @param {number} inputMax - Input maximum
206
+ * @param {boolean} [invert=false] - Invert the output
207
+ * @returns {{msb: number, lsb: number}} MSB and LSB values
208
+ */
209
+ export function normalize14BitValue(value, inputMin, inputMax, invert = false) {
210
+ // Normalize to 0-1
211
+ const normalized = (value - inputMin) / (inputMax - inputMin)
212
+
213
+ // Invert if requested
214
+ const final = invert ? 1 - normalized : normalized
215
+
216
+ // Scale to 14-bit range (0-16383)
217
+ const value14Bit = final * 16383
218
+
219
+ return encode14BitValue(value14Bit)
220
+ }
221
+
222
+ /**
223
+ * Denormalize a 14-bit value to a custom range
224
+ * @param {number} msb - Most significant byte (0-127)
225
+ * @param {number} lsb - Least significant byte (0-127)
226
+ * @param {number} outputMin - Output minimum
227
+ * @param {number} outputMax - Output maximum
228
+ * @param {boolean} [invert=false] - Invert the input
229
+ * @returns {number} Denormalized value
230
+ */
231
+ export function denormalize14BitValue(msb, lsb, outputMin, outputMax, invert = false) {
232
+ const value14Bit = decode14BitValue(msb, lsb)
233
+
234
+ // Normalize to 0-1
235
+ let normalized = value14Bit / 16383
236
+
237
+ // Invert if requested
238
+ if (invert) {
239
+ normalized = 1 - normalized
240
+ }
241
+
242
+ // Scale to output range
243
+ return outputMin + normalized * (outputMax - outputMin)
244
+ }
@@ -0,0 +1,260 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ clamp,
4
+ decode14BitValue,
5
+ denormalize14BitValue,
6
+ denormalizeValue,
7
+ encode14BitValue,
8
+ frequencyToNote,
9
+ getCCName,
10
+ normalize14BitValue,
11
+ normalizeValue,
12
+ noteNameToNumber,
13
+ noteNumberToName,
14
+ noteToFrequency,
15
+ } from "./midi.js"
16
+
17
+ describe("MIDI Utils - Additional Tests", () => {
18
+ describe("denormalizeValue", () => {
19
+ it("should denormalize MIDI values to custom range", () => {
20
+ expect(denormalizeValue(64, 0, 100, false)).toBeCloseTo(50, 0) // 50% of 100
21
+ expect(denormalizeValue(0, 0, 100, false)).toBe(0)
22
+ expect(denormalizeValue(127, 0, 100, false)).toBe(100)
23
+ })
24
+
25
+ it("should invert values when requested", () => {
26
+ expect(denormalizeValue(0, 0, 100, true)).toBe(100)
27
+ expect(denormalizeValue(127, 0, 100, true)).toBe(0)
28
+ expect(denormalizeValue(64, 0, 100, true)).toBeCloseTo(50, 0)
29
+ })
30
+
31
+ it("should handle negative ranges", () => {
32
+ expect(denormalizeValue(0, -50, 50, false)).toBe(-50)
33
+ expect(denormalizeValue(127, -50, 50, false)).toBe(50)
34
+ expect(denormalizeValue(64, -50, 50, false)).toBeCloseTo(0, 0)
35
+ })
36
+ })
37
+
38
+ describe("frequencyToNote", () => {
39
+ it("should convert frequencies to MIDI notes", () => {
40
+ expect(frequencyToNote(440)).toBe(69) // A4
41
+ expect(frequencyToNote(261.63)).toBe(60) // C4
42
+ expect(frequencyToNote(220)).toBe(57) // A3
43
+ })
44
+
45
+ it("should clamp to valid MIDI range", () => {
46
+ expect(frequencyToNote(20000)).toBe(127) // Very high frequency
47
+ expect(frequencyToNote(1)).toBe(0) // Very low frequency
48
+ })
49
+
50
+ it("should round to nearest note", () => {
51
+ expect(frequencyToNote(441)).toBe(69)
52
+ expect(frequencyToNote(439)).toBe(69)
53
+ })
54
+ })
55
+
56
+ describe("noteToFrequency", () => {
57
+ it("should convert MIDI notes to frequencies", () => {
58
+ expect(noteToFrequency(69)).toBeCloseTo(440, 2) // A4
59
+ expect(noteToFrequency(60)).toBeCloseTo(261.63, 2) // C4
60
+ expect(noteToFrequency(57)).toBeCloseTo(220, 2) // A3
61
+ })
62
+
63
+ it("should handle all valid notes", () => {
64
+ expect(noteToFrequency(0)).toBeCloseTo(8.18, 2)
65
+ expect(noteToFrequency(127)).toBeCloseTo(12543.85, 2)
66
+ })
67
+ })
68
+
69
+ describe("clamp - additional edge cases", () => {
70
+ it("should handle equal min and max", () => {
71
+ expect(clamp(5, 10, 10)).toBe(10)
72
+ expect(clamp(15, 10, 10)).toBe(10)
73
+ })
74
+
75
+ it("should handle NaN values", () => {
76
+ expect(clamp(NaN, 0, 100)).toBeNaN()
77
+ })
78
+
79
+ it("should handle infinity", () => {
80
+ expect(clamp(Infinity, 0, 100)).toBe(100)
81
+ expect(clamp(-Infinity, 0, 100)).toBe(0)
82
+ })
83
+ })
84
+
85
+ describe("normalizeValue - additional edge cases", () => {
86
+ it("should handle equal input range", () => {
87
+ // Division by zero case - should handle gracefully
88
+ const result = normalizeValue(50, 50, 50, false)
89
+ expect(result).toBeNaN()
90
+ })
91
+
92
+ it("should handle out of range values", () => {
93
+ expect(normalizeValue(200, 0, 100, false)).toBe(127)
94
+ expect(normalizeValue(-50, 0, 100, false)).toBe(0)
95
+ })
96
+ })
97
+
98
+ describe("noteNameToNumber - additional tests", () => {
99
+ it("should handle all valid note names", () => {
100
+ expect(noteNameToNumber("C0")).toBe(12)
101
+ expect(noteNameToNumber("C#0")).toBe(13)
102
+ expect(noteNameToNumber("Db0")).toBe(13)
103
+ expect(noteNameToNumber("G9")).toBe(127)
104
+ })
105
+
106
+ it("should handle case variations", () => {
107
+ expect(noteNameToNumber("c4")).toBe(60)
108
+ expect(noteNameToNumber("A#4")).toBe(70)
109
+ expect(noteNameToNumber("bb4")).toBe(70)
110
+ })
111
+
112
+ it("should throw on invalid note names", () => {
113
+ expect(() => noteNameToNumber("C#")).toThrow("Invalid note name")
114
+ expect(() => noteNameToNumber("H4")).toThrow("Invalid note")
115
+ // C10 is actually valid (12 * 11 = 132, which gets clamped to 127)
116
+ expect(() => noteNameToNumber("C10")).not.toThrow()
117
+ expect(noteNameToNumber("C10")).toBe(127) // Clamped to max
118
+ })
119
+ })
120
+
121
+ describe("noteNumberToName - additional tests", () => {
122
+ it("should handle all octaves", () => {
123
+ expect(noteNumberToName(0)).toBe("C-1")
124
+ expect(noteNumberToName(127)).toBe("G9")
125
+ })
126
+
127
+ it("should use flats when requested", () => {
128
+ expect(noteNumberToName(70, true)).toBe("Bb4")
129
+ expect(noteNumberToName(72, true)).toBe("C5") // No flat for C
130
+ })
131
+ })
132
+
133
+ describe("getCCName - additional tests", () => {
134
+ it("should return all known CC names", () => {
135
+ expect(getCCName(0)).toBe("Bank Select")
136
+ expect(getCCName(1)).toBe("Modulation")
137
+ expect(getCCName(7)).toBe("Volume")
138
+ expect(getCCName(64)).toBe("Sustain Pedal")
139
+ expect(getCCName(120)).toBe("All Sound Off")
140
+ })
141
+
142
+ it("should return generic format for unknown CCs", () => {
143
+ expect(getCCName(200)).toBe("CC 200")
144
+ expect(getCCName(-1)).toBe("CC -1")
145
+ })
146
+ })
147
+
148
+ describe("14-bit MIDI", () => {
149
+ describe("encode14BitValue", () => {
150
+ it("should encode 0 to MSB=0, LSB=0", () => {
151
+ expect(encode14BitValue(0)).toEqual({ msb: 0, lsb: 0 })
152
+ })
153
+
154
+ it("should encode 16383 to MSB=127, LSB=127", () => {
155
+ expect(encode14BitValue(16383)).toEqual({ msb: 127, lsb: 127 })
156
+ })
157
+
158
+ it("should encode middle value correctly", () => {
159
+ const result = encode14BitValue(8192)
160
+ expect(result.msb).toBe(64)
161
+ expect(result.lsb).toBe(0)
162
+ })
163
+
164
+ it("should clamp values below 0", () => {
165
+ expect(encode14BitValue(-100)).toEqual({ msb: 0, lsb: 0 })
166
+ })
167
+
168
+ it("should clamp values above 16383", () => {
169
+ expect(encode14BitValue(20000)).toEqual({ msb: 127, lsb: 127 })
170
+ })
171
+ })
172
+
173
+ describe("decode14BitValue", () => {
174
+ it("should decode MSB=0, LSB=0 to 0", () => {
175
+ expect(decode14BitValue(0, 0)).toBe(0)
176
+ })
177
+
178
+ it("should decode MSB=127, LSB=127 to 16383", () => {
179
+ expect(decode14BitValue(127, 127)).toBe(16383)
180
+ })
181
+
182
+ it("should decode middle values correctly", () => {
183
+ expect(decode14BitValue(64, 0)).toBe(8192)
184
+ expect(decode14BitValue(32, 0)).toBe(4096)
185
+ expect(decode14BitValue(96, 0)).toBe(12288)
186
+ })
187
+ })
188
+
189
+ describe("encode14BitValue / decode14BitValue round-trip", () => {
190
+ it("should round-trip all edge values", () => {
191
+ const testValues = [0, 1, 127, 128, 16383]
192
+ testValues.forEach((value) => {
193
+ const encoded = encode14BitValue(value)
194
+ const decoded = decode14BitValue(encoded.msb, encoded.lsb)
195
+ expect(decoded).toBe(value)
196
+ })
197
+ })
198
+
199
+ it("should round-trip random values", () => {
200
+ for (let i = 0; i < 100; i++) {
201
+ const value = Math.floor(Math.random() * 16384)
202
+ const encoded = encode14BitValue(value)
203
+ const decoded = decode14BitValue(encoded.msb, encoded.lsb)
204
+ expect(decoded).toBe(value)
205
+ }
206
+ })
207
+ })
208
+
209
+ describe("normalize14BitValue", () => {
210
+ it("should normalize input range to 14-bit MSB/LSB", () => {
211
+ const result = normalize14BitValue(50, 0, 100, false)
212
+ expect(result).toHaveProperty("msb")
213
+ expect(result).toHaveProperty("lsb")
214
+ expect(result.msb).toBeGreaterThanOrEqual(0)
215
+ expect(result.msb).toBeLessThanOrEqual(127)
216
+ expect(result.lsb).toBeGreaterThanOrEqual(0)
217
+ expect(result.lsb).toBeLessThanOrEqual(127)
218
+ })
219
+
220
+ it("should handle min=0, max=127", () => {
221
+ const result = normalize14BitValue(64, 0, 127, false)
222
+ const decoded = decode14BitValue(result.msb, result.lsb)
223
+ // 64/127 = 0.5039, so decoded should be ~8256 (0.5039 * 16383)
224
+ expect(decoded).toBeGreaterThan(8200)
225
+ expect(decoded).toBeLessThan(8300)
226
+ })
227
+
228
+ it("should invert values when requested", () => {
229
+ const result = normalize14BitValue(0, 0, 100, true)
230
+ expect(result.msb).toBe(127)
231
+ expect(result.lsb).toBe(127)
232
+ })
233
+
234
+ it("should handle negative ranges", () => {
235
+ const result = normalize14BitValue(0, -50, 50, false)
236
+ expect(result.msb).toBe(64)
237
+ expect(result.lsb).toBe(0)
238
+ })
239
+ })
240
+
241
+ describe("denormalize14BitValue", () => {
242
+ it("should denormalize 14-bit values to custom range", () => {
243
+ const msb = 64
244
+ const lsb = 0
245
+ const result = denormalize14BitValue(msb, lsb, 0, 100, false)
246
+ expect(result).toBeCloseTo(50, 1)
247
+ })
248
+
249
+ it("should handle min=0, max=16383", () => {
250
+ expect(denormalize14BitValue(0, 0, 0, 16383, false)).toBe(0)
251
+ expect(denormalize14BitValue(127, 127, 0, 16383, false)).toBe(16383)
252
+ })
253
+
254
+ it("should invert values when requested", () => {
255
+ const result = denormalize14BitValue(0, 0, 0, 100, true)
256
+ expect(result).toBeCloseTo(100, 0)
257
+ })
258
+ })
259
+ })
260
+ })
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Parse a SysEx message
3
+ * @param {Array<number>} data - Raw MIDI data
4
+ * @returns {Object|null} Parsed SysEx data or null if not SysEx
5
+ */
6
+ export function parseSysEx(data) {
7
+ if (data[0] !== 0xf0 || data[data.length - 1] !== 0xf7) {
8
+ return null
9
+ }
10
+
11
+ return {
12
+ manufacturerId: data[1],
13
+ payload: data.slice(2, -1),
14
+ raw: data,
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Create a SysEx message
20
+ * @param {number} manufacturerId - Manufacturer ID
21
+ * @param {Array<number>} payload - SysEx payload data
22
+ * @returns {Array<number>} Complete SysEx message with F0/F7
23
+ */
24
+ export function createSysEx(manufacturerId, payload) {
25
+ return [0xf0, manufacturerId, ...payload, 0xf7]
26
+ }
27
+
28
+ /**
29
+ * Check if data is a SysEx message
30
+ * @param {Array<number>} data - MIDI data
31
+ * @returns {boolean}
32
+ */
33
+ export function isSysEx(data) {
34
+ return data.length >= 2 && data[0] === 0xf0 && data[data.length - 1] === 0xf7
35
+ }
36
+
37
+ /**
38
+ * Encode 8-bit data to 7-bit MIDI format
39
+ * @param {Array<number>} data - 8-bit data array
40
+ * @returns {Array<number>} 7-bit encoded data
41
+ */
42
+ export function encode7Bit(data) {
43
+ const result = []
44
+
45
+ // Process data in groups of 7 bytes
46
+ for (let i = 0; i < data.length; i += 7) {
47
+ const group = data.slice(i, i + 7)
48
+ let headerByte = 0
49
+ const dataBytes = []
50
+
51
+ // Process each byte in the group
52
+ for (let j = 0; j < group.length; j++) {
53
+ const byte = group[j]
54
+ // Check if MSB (bit 7) is set
55
+ if (byte & 0x80) {
56
+ headerByte |= 1 << j
57
+ }
58
+ // Add the lower 7 bits as a data byte
59
+ dataBytes.push(byte & 0x7f)
60
+ }
61
+
62
+ // Add header byte followed by the data bytes
63
+ // Note: result length may not be a multiple of 8
64
+ result.push(headerByte, ...dataBytes)
65
+ }
66
+
67
+ return result
68
+ }
69
+
70
+ /**
71
+ * Decode 7-bit MIDI format to 8-bit data
72
+ * @param {Array<number>} data - 7-bit encoded data
73
+ * @returns {Array<number>} 8-bit decoded data
74
+ */
75
+ export function decode7Bit(data) {
76
+ const result = []
77
+
78
+ // Process data in groups of 8 bytes (1 header + 7 data bytes)
79
+ for (let i = 0; i < data.length; i += 8) {
80
+ const headerByte = data[i]
81
+ // Calculate how many data bytes are in this group (1-7)
82
+ const groupSize = Math.min(7, data.length - i - 1)
83
+
84
+ // Process the data bytes in this group
85
+ for (let j = 0; j < groupSize; j++) {
86
+ let byte = data[i + 1 + j]
87
+
88
+ // Reconstruct the 8-bit value using header bit
89
+ if (headerByte & (1 << j)) {
90
+ byte |= 0x80
91
+ }
92
+
93
+ result.push(byte)
94
+ }
95
+ }
96
+
97
+ return result
98
+ }