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,1208 @@
1
+ import { beforeAll, describe, expect, it } from "vitest"
2
+ import { DX7Bank, DX7Voice } from "./dx7.js"
3
+
4
+ describe("DX7Voice", () => {
5
+ describe("constructor", () => {
6
+ it("should create a voice from 128 bytes of data", () => {
7
+ const data = new Array(DX7Voice.PACKED_SIZE).fill(0)
8
+ const voice = new DX7Voice(data, 5)
9
+
10
+ expect(voice.index).toBe(5)
11
+ expect(voice.data.length).toBe(DX7Voice.PACKED_SIZE)
12
+ expect(voice.name).toBe("")
13
+ })
14
+
15
+ it("should throw error for invalid data length", () => {
16
+ expect(() => new DX7Voice([1, 2, 3])).toThrow("Invalid voice data length")
17
+ expect(() => new DX7Voice(new Array(DX7Voice.PACKED_SIZE - 1).fill(0))).toThrow(
18
+ "Invalid voice data length",
19
+ )
20
+ expect(() => new DX7Voice(new Array(DX7Voice.PACKED_SIZE + 1).fill(0))).toThrow(
21
+ "Invalid voice data length",
22
+ )
23
+ })
24
+
25
+ it("should extract voice name correctly", () => {
26
+ const data = new Array(DX7Voice.PACKED_SIZE).fill(0)
27
+ // Set name bytes (offset 118-127) to 'TEST VOICE'
28
+ const name = "TEST VOICE"
29
+ for (let i = 0; i < DX7Voice.NAME_LENGTH; i++) {
30
+ data[DX7Voice.PACKED_NAME_START + i] = name.charCodeAt(i) || DX7Voice.CHAR_SPACE
31
+ }
32
+
33
+ const voice = new DX7Voice(data)
34
+ expect(voice.name).toBe("TEST VOICE")
35
+ })
36
+
37
+ it("should normalize DX7 special characters", () => {
38
+ const data = new Array(DX7Voice.PACKED_SIZE).fill(0)
39
+ // Set special characters
40
+ data[DX7Voice.PACKED_NAME_START] = DX7Voice.CHAR_YEN
41
+ data[DX7Voice.PACKED_NAME_START + 1] = DX7Voice.CHAR_ARROW_RIGHT
42
+ data[DX7Voice.PACKED_NAME_START + 2] = DX7Voice.CHAR_ARROW_LEFT
43
+ data[121] = DX7Voice.CHAR_SPACE
44
+
45
+ const voice = new DX7Voice(data)
46
+ expect(voice.name).toBe("Y><") // Position 121 is space, trim removes trailing spaces
47
+ })
48
+ })
49
+
50
+ describe("getParameter and setParameter", () => {
51
+ it("should get and set parameters correctly", () => {
52
+ const data = new Array(DX7Voice.PACKED_SIZE).fill(0)
53
+ const voice = new DX7Voice(data)
54
+
55
+ // Set parameter at offset 10 to value 64
56
+ voice.setParameter(10, 64)
57
+ expect(voice.getParameter(10)).toBe(64)
58
+ })
59
+
60
+ it("should mask values to 7-bit range", () => {
61
+ const data = new Array(DX7Voice.PACKED_SIZE).fill(0)
62
+ const voice = new DX7Voice(data)
63
+
64
+ voice.setParameter(10, 200) // Should be masked to 72 (200 & DX7Voice.MASK_7BIT)
65
+ expect(voice.getParameter(10)).toBe(72)
66
+ })
67
+
68
+ it("should update name when name bytes change", () => {
69
+ const data = new Array(DX7Voice.PACKED_SIZE).fill(0)
70
+ const voice = new DX7Voice(data)
71
+
72
+ // Set name bytes
73
+ voice.setParameter(DX7Voice.PACKED_NAME_START, "A".charCodeAt(0))
74
+ voice.setParameter(DX7Voice.PACKED_NAME_START + 1, "B".charCodeAt(0))
75
+ voice.setParameter(DX7Voice.PACKED_NAME_START + 2, "C".charCodeAt(0))
76
+
77
+ expect(voice.name).toBe("ABC")
78
+ })
79
+ })
80
+
81
+ describe("getUnpackedParameter", () => {
82
+ it("should read algorithm from unpacked data", () => {
83
+ const data = new Array(DX7Voice.PACKED_SIZE).fill(0)
84
+ // Algorithm is at packed[110], maps to unpacked[146]
85
+ data[DX7Voice.OFFSET_ALGORITHM] = 5 // Algorithm 6 (0-indexed)
86
+
87
+ const voice = new DX7Voice(data)
88
+ expect(voice.getUnpackedParameter(DX7Voice.UNPACKED_ALGORITHM)).toBe(5)
89
+ })
90
+
91
+ it("should read feedback from unpacked data", () => {
92
+ const data = new Array(DX7Voice.PACKED_SIZE).fill(0)
93
+ // Feedback is at packed[111], maps to unpacked[147]
94
+ data[DX7Voice.OFFSET_FEEDBACK] = 7 | 0 // Max feedback, OSC Sync is 0
95
+ // Bit 3 is OSC Sync, bits 0-2 are Feedback
96
+
97
+ const voice = new DX7Voice(data)
98
+ expect(voice.getUnpackedParameter(DX7Voice.UNPACKED_FEEDBACK)).toBe(7)
99
+ })
100
+
101
+ it("should read LFO speed from unpacked data", () => {
102
+ const data = new Array(DX7Voice.PACKED_SIZE).fill(0)
103
+ // LFO Speed is at packed[112], maps to unpacked[149]
104
+ data[DX7Voice.OFFSET_LFO_SPEED] = 50
105
+
106
+ const voice = new DX7Voice(data)
107
+ expect(voice.getUnpackedParameter(DX7Voice.UNPACKED_LFO_SPEED)).toBe(50)
108
+ })
109
+ })
110
+
111
+ describe("unpack", () => {
112
+ it("should unpack 128-byte data to 165-byte format", () => {
113
+ const data = new Array(DX7Voice.PACKED_SIZE).fill(0)
114
+ const voice = new DX7Voice(data)
115
+ const unpacked = voice.unpack()
116
+
117
+ expect(unpacked.length).toBe(DX7Voice.UNPACKED_SIZE)
118
+ })
119
+
120
+ it("should unpack with correct operator structure", () => {
121
+ const data = new Array(DX7Voice.PACKED_SIZE).fill(0)
122
+ // DX7 stores operators in reverse order: OP6 at packed[0], OP1 at packed[85-101]
123
+ // Set OP1 values at packed[85-101]
124
+ data[85] = 10 // OP1 EG Rate 1 at packed[85]
125
+ data[89] = 20 // OP1 EG Level 1 at packed[89] (85 + 4)
126
+ // Set OP2 EG Rate 4 at packed[71] (packed[68-84] is OP2, EG rates are bytes 0-3)
127
+ data[71] = 30 // OP2 EG Rate 4 at packed[68+3]
128
+
129
+ const voice = new DX7Voice(data)
130
+ const unpacked = voice.unpack()
131
+
132
+ expect(unpacked[0]).toBe(10) // OP1 EG Rate 1 <- packed[85]
133
+ expect(unpacked[4]).toBe(20) // OP1 EG Level 1 <- packed[89]
134
+ // OP2 EG Rate 1-4: unpacked[23-26] <- packed[68-71]
135
+ expect(unpacked[23]).toBe(data[68]) // OP2 EG Rate 1 <- packed[68]
136
+ expect(unpacked[26]).toBe(30) // OP2 EG Rate 4 <- packed[71]
137
+ })
138
+ })
139
+
140
+ describe("pack", () => {
141
+ it("should pack 169-byte data to 128-byte format", () => {
142
+ const unpacked = new Array(DX7Voice.UNPACKED_SIZE).fill(0)
143
+ const packed = DX7Voice.pack(unpacked)
144
+
145
+ expect(packed.length).toBe(DX7Voice.PACKED_SIZE)
146
+ })
147
+
148
+ it("should throw error for invalid unpacked length", () => {
149
+ expect(() => DX7Voice.pack(new Array(100).fill(0))).toThrow("Invalid unpacked data length")
150
+ expect(() => DX7Voice.pack(new Array(200).fill(0))).toThrow("Invalid unpacked data length")
151
+ })
152
+
153
+ it("should pack and unpack correctly", () => {
154
+ const originalData = new Array(DX7Voice.PACKED_SIZE).fill(0)
155
+ originalData[0] = 50
156
+ originalData[10] = 100
157
+ originalData[DX7Voice.OFFSET_ALGORITHM] = 5 // Algorithm 6 (packed[110])
158
+
159
+ const voice = new DX7Voice(originalData)
160
+ const unpacked = voice.unpack()
161
+ const repacked = DX7Voice.pack(unpacked)
162
+
163
+ expect(repacked.length).toBe(DX7Voice.PACKED_SIZE)
164
+ expect(repacked[0]).toBe(50)
165
+ expect(repacked[10]).toBe(100)
166
+ expect(repacked[DX7Voice.OFFSET_ALGORITHM]).toBe(5) // Algorithm preserved
167
+ })
168
+ })
169
+
170
+ describe("fromUnpacked", () => {
171
+ it("should create a voice from unpacked data", () => {
172
+ const unpacked = new Array(DX7Voice.UNPACKED_SIZE).fill(0)
173
+ unpacked[0] = 99 // OP1 EG Rate 1
174
+ unpacked[4] = 50 // OP1 EG Level 1
175
+
176
+ const voice = DX7Voice.fromUnpacked(unpacked, 3)
177
+
178
+ expect(voice.index).toBe(3)
179
+ expect(voice.data.length).toBe(DX7Voice.PACKED_SIZE)
180
+ // OP1 data goes to packed[85-101] (operators are stored in reverse order)
181
+ expect(voice.getParameter(85)).toBe(99) // OP1 EG Rate 1 at packed[85]
182
+ })
183
+ })
184
+
185
+ describe("createDefault", () => {
186
+ it("should create a default voice", () => {
187
+ const voice = DX7Voice.createDefault(7)
188
+
189
+ expect(voice.index).toBe(7)
190
+ expect(voice.data.length).toBe(DX7Voice.PACKED_SIZE)
191
+ expect(voice.name).toBe("Init Voice")
192
+ })
193
+
194
+ it("should create a valid voice that can be unpacked", () => {
195
+ const voice = DX7Voice.createDefault()
196
+ const unpacked = voice.unpack()
197
+
198
+ expect(unpacked.length).toBe(DX7Voice.UNPACKED_SIZE) // 159 parameters + 10 name bytes
199
+ expect(unpacked[0]).toBe(99) // Default EG rate
200
+ // Note: Algorithm position varies based on createDefault implementation
201
+ // The key test is that pack/unpack round-trip works correctly
202
+ })
203
+ })
204
+ })
205
+
206
+ describe("DX7Bank", () => {
207
+ describe("constructor", () => {
208
+ it("should create empty bank with default voices", () => {
209
+ const bank = new DX7Bank()
210
+
211
+ expect(bank.voices.length).toBe(DX7Bank.NUM_VOICES)
212
+ expect(bank.getVoice(0).name).toBe("Init Voice")
213
+ })
214
+
215
+ it("should load data from SYX format", () => {
216
+ // Create minimal valid SYX data
217
+ const data = new Uint8Array(DX7Bank.SYSEX_SIZE)
218
+ data[0] = 0xf0
219
+ DX7Bank.SYSEX_HEADER.forEach((byte, i) => {
220
+ data[i] = byte
221
+ })
222
+ // Fill with valid voice data
223
+ for (
224
+ let i = DX7Bank.SYSEX_HEADER_SIZE;
225
+ i < DX7Bank.SYSEX_HEADER_SIZE + DX7Bank.VOICE_DATA_SIZE;
226
+ i++
227
+ ) {
228
+ data[i] = 0
229
+ }
230
+ data[DX7Bank.SYSEX_SIZE - 1] = DX7Bank.MASK_7BIT
231
+
232
+ const bank = new DX7Bank(data)
233
+ expect(bank.voices.length).toBe(DX7Bank.NUM_VOICES)
234
+ })
235
+
236
+ it("should throw error for invalid header", () => {
237
+ const data = new Uint8Array(DX7Bank.SYSEX_SIZE)
238
+ data[0] = 0xf0
239
+ data[1] = 0x42 // Wrong manufacturer
240
+
241
+ expect(() => new DX7Bank(data)).toThrow("Invalid SysEx header")
242
+ })
243
+
244
+ it("should throw error for invalid length", () => {
245
+ expect(() => new DX7Bank(new Uint8Array(100))).toThrow("Invalid data length")
246
+ })
247
+ })
248
+
249
+ describe("_calculateChecksum", () => {
250
+ it("should calculate checksum correctly", () => {
251
+ const data = new Uint8Array([1, 2, 3, 4, 5])
252
+ const checksum = DX7Bank._calculateChecksum(data, 5)
253
+
254
+ // Manual calculation: sum = 15, checksum = 128 - 15 = 113
255
+ expect(checksum).toBe(113)
256
+ })
257
+
258
+ it("should handle large data", () => {
259
+ const data = new Uint8Array(DX7Bank.VOICE_DATA_SIZE).fill(0) // All zeros
260
+ const checksum = DX7Bank._calculateChecksum(data, 4096)
261
+
262
+ expect(checksum).toBe(0) // 128 - (0 % 128) = 128, masked to 0
263
+ })
264
+ })
265
+
266
+ describe("replaceVoice", () => {
267
+ it("should replace a voice at specified index", () => {
268
+ const bank = new DX7Bank()
269
+ const newPatch = DX7Voice.createDefault()
270
+
271
+ // Set a parameter to mark it as different
272
+ newPatch.setParameter(0, 42)
273
+
274
+ bank.replaceVoice(5, newPatch)
275
+
276
+ expect(bank.getVoice(5).getParameter(0)).toBe(42)
277
+ })
278
+
279
+ it("should throw error for invalid index", () => {
280
+ const bank = new DX7Bank()
281
+ const voice = DX7Voice.createDefault()
282
+
283
+ expect(() => bank.replaceVoice(-1, voice)).toThrow("Invalid voice index")
284
+ expect(() => bank.replaceVoice(32, voice)).toThrow("Invalid voice index")
285
+ })
286
+
287
+ it("should create a copy of the voice", () => {
288
+ const bank = new DX7Bank()
289
+ const originalPatch = DX7Voice.createDefault()
290
+ originalPatch.setParameter(0, 99)
291
+
292
+ bank.replaceVoice(0, originalPatch)
293
+
294
+ // Modify original voice
295
+ originalPatch.setParameter(0, 55)
296
+
297
+ // Bank voice should be unchanged
298
+ expect(bank.getVoice(0).getParameter(0)).toBe(99)
299
+ })
300
+ })
301
+
302
+ describe("addVoice", () => {
303
+ it("should add voice to first empty slot", () => {
304
+ const bank = new DX7Bank()
305
+ const newPatch = DX7Voice.createDefault()
306
+ newPatch.setParameter(0, 77)
307
+
308
+ const index = bank.addVoice(newPatch)
309
+
310
+ expect(index).toBe(0) // First slot is empty
311
+ expect(bank.getVoice(index).getParameter(0)).toBe(77)
312
+ })
313
+
314
+ it("should return -1 when bank is full", () => {
315
+ const bank = new DX7Bank()
316
+ const newPatch = DX7Voice.createDefault()
317
+
318
+ // Replace all voices with a named voice
319
+ for (let i = 0; i < DX7Bank.NUM_VOICES; i++) {
320
+ const voice = DX7Voice.createDefault()
321
+ voice.setParameter(DX7Voice.PACKED_NAME_START, "A".charCodeAt(0)) // Set name to 'A'
322
+ bank.replaceVoice(i, voice)
323
+ }
324
+
325
+ const index = bank.addVoice(newPatch)
326
+ expect(index).toBe(-1)
327
+ })
328
+ })
329
+
330
+ describe("getVoice", () => {
331
+ it("should return voice at valid index", () => {
332
+ const bank = new DX7Bank()
333
+ const voice = bank.getVoice(5)
334
+
335
+ expect(voice).not.toBeNull()
336
+ expect(voice.index).toBe(5)
337
+ })
338
+
339
+ it("should return null for invalid index", () => {
340
+ const bank = new DX7Bank()
341
+
342
+ expect(bank.getVoice(-1)).toBeNull()
343
+ expect(bank.getVoice(32)).toBeNull()
344
+ })
345
+ })
346
+
347
+ describe("getVoiceNames", () => {
348
+ it("should return all voice names", () => {
349
+ const bank = new DX7Bank()
350
+ const names = bank.getVoiceNames()
351
+
352
+ expect(names.length).toBe(DX7Bank.NUM_VOICES)
353
+ expect(names[0]).toBe("Init Voice")
354
+ })
355
+ })
356
+
357
+ describe("findVoiceByName", () => {
358
+ it("should find voice by exact name", () => {
359
+ const bank = new DX7Bank()
360
+
361
+ // Replace a voice with a named one
362
+ const voice = DX7Voice.createDefault()
363
+ const nameData = "SUPER BASS".split("").map((c) => c.charCodeAt(0))
364
+ for (let i = 0; i < DX7Voice.NAME_LENGTH; i++) {
365
+ voice.setParameter(DX7Voice.PACKED_NAME_START + i, nameData[i] || 32)
366
+ }
367
+ bank.replaceVoice(10, voice)
368
+
369
+ const found = bank.findVoiceByName("SUPER BASS")
370
+ expect(found).not.toBeNull()
371
+ expect(found.name).toBe("SUPER BASS")
372
+ })
373
+
374
+ it("should find voice by partial name (case-insensitive)", () => {
375
+ const bank = new DX7Bank()
376
+
377
+ const voice = DX7Voice.createDefault()
378
+ const nameData = "E.PIANO 1".split("").map((c) => c.charCodeAt(0))
379
+ for (let i = 0; i < DX7Voice.NAME_LENGTH; i++) {
380
+ voice.setParameter(DX7Voice.PACKED_NAME_START + i, nameData[i] || 32)
381
+ }
382
+ bank.replaceVoice(15, voice)
383
+
384
+ const found = bank.findVoiceByName("piano")
385
+ expect(found).not.toBeNull()
386
+ expect(found.name).toBe("E.PIANO 1")
387
+ })
388
+
389
+ it("should return null when voice not found", () => {
390
+ const bank = new DX7Bank()
391
+
392
+ const found = bank.findVoiceByName("NONEXISTENT")
393
+ expect(found).toBeNull()
394
+ })
395
+ })
396
+
397
+ describe("toSysex", () => {
398
+ it("should export to SYSEX format", () => {
399
+ const bank = new DX7Bank()
400
+ const sysex = bank.toSysEx()
401
+
402
+ expect(sysex.length).toBe(DX7Bank.SYSEX_SIZE)
403
+ expect(sysex[0]).toBe(DX7Bank.SYSEX_START)
404
+ expect(sysex[DX7Bank.SYSEX_SIZE - 1]).toBe(DX7Bank.SYSEX_END)
405
+ })
406
+
407
+ it("should include correct header", () => {
408
+ const bank = new DX7Bank()
409
+ const sysex = bank.toSysEx()
410
+ const header = sysex.slice(0, 6)
411
+
412
+ expect(Array.from(header)).toEqual(DX7Bank.SYSEX_HEADER)
413
+ })
414
+
415
+ it("should maintain data integrity", () => {
416
+ const originalBank = new DX7Bank()
417
+
418
+ // Modify a voice
419
+ const voice = DX7Voice.createDefault()
420
+ voice.setParameter(0, 123)
421
+ // Clear all name bytes first
422
+ for (let i = 0; i < DX7Voice.NAME_LENGTH; i++) {
423
+ voice.setParameter(DX7Voice.PACKED_NAME_START + i, 32) // Space
424
+ }
425
+ // Set only first 2 chars
426
+ voice.setParameter(DX7Voice.PACKED_NAME_START, "A".charCodeAt(0))
427
+ voice.setParameter(DX7Voice.PACKED_NAME_START + 1, "B".charCodeAt(0))
428
+ originalBank.replaceVoice(7, voice)
429
+
430
+ // Export and reimport
431
+ const sysex = originalBank.toSysEx()
432
+ const newBank = new DX7Bank(sysex)
433
+
434
+ expect(newBank.getVoice(7).getParameter(0)).toBe(123)
435
+ expect(newBank.getVoice(7).name).toBe("AB")
436
+ })
437
+ })
438
+
439
+ describe("fromFile", () => {
440
+ it("should load bank from file", async () => {
441
+ const data = new Uint8Array(DX7Bank.SYSEX_SIZE)
442
+ data[0] = 0xf0
443
+ DX7Bank.SYSEX_HEADER.forEach((byte, i) => {
444
+ data[i] = byte
445
+ })
446
+ // Fill with valid voice data
447
+ for (
448
+ let i = DX7Bank.SYSEX_HEADER_SIZE;
449
+ i < DX7Bank.SYSEX_HEADER_SIZE + DX7Bank.VOICE_DATA_SIZE;
450
+ i++
451
+ ) {
452
+ data[i] = 0
453
+ }
454
+ data[DX7Bank.SYSEX_SIZE - 1] = DX7Voice.MASK_7BIT
455
+
456
+ const blob = new Blob([data])
457
+ const file = new File([blob], "test.syx")
458
+
459
+ const bank = await DX7Bank.fromFile(file)
460
+ expect(bank.voices.length).toBe(DX7Bank.NUM_VOICES)
461
+ })
462
+
463
+ it("should reject on invalid file", async () => {
464
+ const data = new Uint8Array(100) // Too small
465
+ const blob = new Blob([data])
466
+ const file = new File([blob], "invalid.syx")
467
+
468
+ await expect(DX7Bank.fromFile(file)).rejects.toThrow()
469
+ })
470
+ })
471
+
472
+ describe("bank workflow", () => {
473
+ it("should handle complete create, modify, export workflow", () => {
474
+ // Create empty bank
475
+ const bank = new DX7Bank()
476
+
477
+ // Create a custom voice
478
+ const voice = DX7Voice.createDefault()
479
+ voice.setParameter(0, 50)
480
+ voice.setParameter(4, 75)
481
+ voice.setParameter(8, 60) // Break point
482
+
483
+ // Set voice name - clear all first then set
484
+ const name = "MY VOICE"
485
+ for (let i = 0; i < DX7Voice.NAME_LENGTH; i++) {
486
+ voice.setParameter(DX7Voice.PACKED_NAME_START + i, 32) // Space
487
+ }
488
+ for (let i = 0; i < name.length; i++) {
489
+ voice.setParameter(DX7Voice.PACKED_NAME_START + i, name.charCodeAt(i))
490
+ }
491
+
492
+ // Add to bank
493
+ bank.replaceVoice(0, voice)
494
+
495
+ // Verify
496
+ expect(bank.getVoice(0).name).toBe("MY VOICE")
497
+ expect(bank.getVoice(0).getParameter(0)).toBe(50)
498
+ expect(bank.getVoice(0).getParameter(4)).toBe(75)
499
+
500
+ // Export to SYSEX
501
+ const sysex = bank.toSysEx()
502
+ expect(sysex.length).toBe(DX7Bank.SYSEX_SIZE)
503
+
504
+ // Import back
505
+ const importedBank = new DX7Bank(sysex)
506
+ expect(importedBank.getVoice(0).name).toBe("MY VOICE")
507
+ })
508
+ })
509
+ })
510
+
511
+ describe("Real DX7 ROM Bank (ROM1A.syx)", () => {
512
+ let bank
513
+
514
+ beforeAll(async () => {
515
+ // Load the real DX7 ROM file from fixtures directory
516
+ const fs = await import("node:fs")
517
+ const path = await import("node:path")
518
+ const fixturesPath = path.join(__dirname, "../../fixtures/ROM1A.syx")
519
+ const data = fs.readFileSync(fixturesPath)
520
+ bank = new DX7Bank(data)
521
+ })
522
+
523
+ describe("basic bank properties", () => {
524
+ it("should load valid DX7 bank with 32 voices", () => {
525
+ expect(bank.voices.length).toBe(DX7Bank.NUM_VOICES)
526
+ })
527
+
528
+ it("should have valid sysex structure", () => {
529
+ const sysex = bank.toSysEx()
530
+ expect(sysex[0]).toBe(DX7Bank.SYSEX_START) // SysEx start
531
+ expect(sysex[DX7Bank.SYSEX_SIZE - 1]).toBe(DX7Bank.SYSEX_END) // SysEx end
532
+ expect(sysex.length).toBe(DX7Bank.SYSEX_SIZE)
533
+ })
534
+
535
+ it("should have correct DX7 header", () => {
536
+ const sysex = bank.toSysEx()
537
+ const header = sysex.slice(0, DX7Bank.SYSEX_HEADER_SIZE)
538
+ expect(Array.from(header)).toEqual(DX7Bank.SYSEX_HEADER)
539
+ })
540
+ })
541
+
542
+ describe("voice validation", () => {
543
+ it("should have all non-empty voice names", () => {
544
+ const names = bank.getVoiceNames()
545
+ names.forEach((name) => {
546
+ expect(name.length).toBeGreaterThan(0)
547
+ expect(name).not.toBe("")
548
+ })
549
+ })
550
+
551
+ it("should find known DX7 ROM voices", () => {
552
+ // These are well-known voices from the DX7 ROM (with actual spacing from ROM)
553
+ expect(bank.findVoiceByName("BRASS")).not.toBeNull() // Partial match for "BRASS 1"
554
+ expect(bank.findVoiceByName("E.PIANO")).not.toBeNull() // Partial match for "E.PIANO 1"
555
+ expect(bank.findVoiceByName("BASS")).not.toBeNull() // Partial match for "BASS 1"
556
+ expect(bank.findVoiceByName("PIANO")).not.toBeNull() // Partial match for "PIANO 1"
557
+ })
558
+
559
+ it("should have valid voice parameters", () => {
560
+ const voice = bank.getVoice(0)
561
+ expect(voice).not.toBeNull()
562
+ expect(voice.data.length).toBe(DX7Voice.PACKED_SIZE)
563
+
564
+ // All parameters should be in valid range (0-127)
565
+ for (let i = 0; i < DX7Voice.PACKED_SIZE; i++) {
566
+ const param = voice.getParameter(i)
567
+ expect(param).toBeGreaterThanOrEqual(0)
568
+ expect(param).toBeLessThanOrEqual(127)
569
+ }
570
+ })
571
+
572
+ it("should unpack real voices correctly", () => {
573
+ const voice = bank.getVoice(0)
574
+ const unpacked = voice.unpack()
575
+
576
+ expect(unpacked.length).toBe(DX7Voice.UNPACKED_SIZE) // 159 parameters + 10 name bytes
577
+
578
+ // Check EG rates are in valid range
579
+ for (let op = 0; op < DX7Voice.NUM_OPERATORS; op++) {
580
+ const opOffset = op * DX7Voice.UNPACKED_OP_SIZE
581
+ for (let i = 0; i < 4; i++) {
582
+ expect(unpacked[opOffset + i]).toBeGreaterThanOrEqual(0)
583
+ expect(unpacked[opOffset + i]).toBeLessThanOrEqual(99) // EG rates max 99
584
+ }
585
+ }
586
+ })
587
+ })
588
+
589
+ describe("data integrity tests", () => {
590
+ it("should maintain data integrity through pack/unpack cycle", () => {
591
+ const originalPatch = bank.getVoice(5)
592
+ const unpacked = originalPatch.unpack()
593
+ const repacked = DX7Voice.pack(unpacked)
594
+ const newPatch = new DX7Voice(repacked, 5)
595
+
596
+ // Compare all 128 bytes - pack/unpack normalizes the data
597
+ // so we check that packed values match (with 7-bit mask applied)
598
+ for (let i = 0; i < DX7Voice.PACKED_SIZE; i++) {
599
+ expect(newPatch.data[i]).toBe(originalPatch.data[i] & DX7Voice.MASK_7BIT)
600
+ }
601
+ })
602
+
603
+ it("should export and reimport without data loss", () => {
604
+ const originalSysex = bank.toSysEx()
605
+ const newBank = new DX7Bank(originalSysex)
606
+
607
+ // Compare all voices
608
+ for (let i = 0; i < DX7Bank.NUM_VOICES; i++) {
609
+ const originalPatch = bank.getVoice(i)
610
+ const newPatch = newBank.getVoice(i)
611
+
612
+ expect(newPatch.data.length).toBe(originalPatch.data.length)
613
+ for (let j = 0; j < DX7Voice.PACKED_SIZE; j++) {
614
+ expect(newPatch.data[j]).toBe(originalPatch.data[j])
615
+ }
616
+ expect(newPatch.name).toBe(originalPatch.name)
617
+ }
618
+ })
619
+
620
+ it("should validate specific known voice parameters", () => {
621
+ // Test a few known voices from the DX7 ROM
622
+ const brass1 = bank.findVoiceByName("BRASS")
623
+ expect(brass1).not.toBeNull()
624
+
625
+ // Should have some operators active
626
+ const unpacked = brass1.unpack()
627
+ let activeOperators = 0
628
+ for (let op = 0; op < DX7Voice.NUM_OPERATORS; op++) {
629
+ const opOffset = op * DX7Voice.UNPACKED_OP_SIZE
630
+ if (unpacked[opOffset + 16] > 0) {
631
+ activeOperators++
632
+ }
633
+ }
634
+ expect(activeOperators).toBeGreaterThan(0)
635
+ })
636
+ })
637
+
638
+ describe("edge cases", () => {
639
+ it("should handle voice extraction at all indices", () => {
640
+ // Verify we can access all 32 voices
641
+ for (let i = 0; i < DX7Bank.NUM_VOICES; i++) {
642
+ const voice = bank.getVoice(i)
643
+ expect(voice).not.toBeNull()
644
+ expect(voice.index).toBe(i)
645
+ expect(voice.data.length).toBe(DX7Voice.PACKED_SIZE)
646
+ }
647
+ })
648
+
649
+ it("should preserve voice names exactly", () => {
650
+ const originalNames = bank.getVoiceNames()
651
+ const sysex = bank.toSysEx()
652
+ const newBank = new DX7Bank(sysex)
653
+ const newNames = newBank.getVoiceNames()
654
+
655
+ expect(newNames).toEqual(originalNames)
656
+ })
657
+
658
+ it("should handle voice replacement and maintain order", () => {
659
+ const originalPatch4 = bank.getVoice(4)
660
+ const _originalPatch5 = bank.getVoice(5)
661
+ const originalPatch6 = bank.getVoice(6)
662
+ const newPatch = DX7Voice.createDefault()
663
+
664
+ // Set a unique name (pad with spaces like DX7 does)
665
+ const testName = "TESTPATCH"
666
+ for (let i = 0; i < DX7Voice.NAME_LENGTH; i++) {
667
+ newPatch.setParameter(
668
+ 118 + i,
669
+ i < testName.length ? testName.charCodeAt(i) : DX7Voice.CHAR_SPACE,
670
+ )
671
+ }
672
+
673
+ bank.replaceVoice(5, newPatch)
674
+
675
+ expect(bank.getVoice(5).name).toBe("TESTPATCH")
676
+ expect(bank.getVoice(4).name).toBe(originalPatch4.name)
677
+ expect(bank.getVoice(6).name).toBe(originalPatch6.name)
678
+ })
679
+ })
680
+
681
+ describe("toJSON", () => {
682
+ it("should convert BASS 1 voice to correct JSON format", () => {
683
+ // Get voice 14 which is BASS 1 (based on ROM voice list)
684
+ const bassPatch = bank.getVoice(14)
685
+ expect(bassPatch).not.toBeNull()
686
+ expect(bassPatch.name).toBe("BASS 1")
687
+
688
+ const json = bassPatch.toJSON()
689
+
690
+ // Verify voice name (includes padding)
691
+ expect(json.name).toBe("BASS 1")
692
+
693
+ // Verify global parameters
694
+ expect(json.global.algorithm).toBe(16)
695
+ expect(json.global.feedback).toBe(7)
696
+ expect(json.global.oscKeySync).toBe(true)
697
+ expect(json.global.pitchModSens).toBe(3)
698
+ expect(json.global.transpose).toBe(-12)
699
+
700
+ // Verify LFO parameters
701
+ expect(json.lfo.speed).toBe(35)
702
+ expect(json.lfo.delay).toBe(0)
703
+ expect(json.lfo.pmDepth).toBe(0)
704
+ expect(json.lfo.amDepth).toBe(0)
705
+ expect(json.lfo.keySync).toBe(false)
706
+ expect(json.lfo.wave).toBe("TRIANGLE")
707
+
708
+ // Verify pitch EG
709
+ expect(json.pitchEG.rates).toEqual([94, 67, 95, 60])
710
+ expect(json.pitchEG.levels).toEqual([50, 50, 50, 50])
711
+
712
+ // Verify operators count
713
+ expect(json.operators).toHaveLength(6)
714
+
715
+ // Verify OP1
716
+ expect(json.operators[0]).toEqual({
717
+ id: 1,
718
+ osc: { detune: 0, freq: { coarse: 0, fine: 0, mode: "RATIO" } },
719
+ eg: { rates: [95, 62, 17, 58], levels: [99, 95, 32, 0] },
720
+ key: { velocity: 0, scaling: 7, breakPoint: "A2" },
721
+ output: { level: 99, ampModSens: 0 },
722
+ scale: { left: { depth: 57, curve: "+LN" }, right: { depth: 14, curve: "-LN" } },
723
+ })
724
+
725
+ // Verify OP2
726
+ expect(json.operators[1]).toEqual({
727
+ id: 2,
728
+ osc: { detune: 0, freq: { coarse: 0, fine: 0, mode: "RATIO" } },
729
+ eg: { rates: [99, 20, 0, 0], levels: [99, 0, 0, 0] },
730
+ key: { velocity: 0, scaling: 7, breakPoint: "D3" },
731
+ output: { level: 80, ampModSens: 0 },
732
+ scale: { left: { depth: 0, curve: "-LN" }, right: { depth: 0, curve: "-LN" } },
733
+ })
734
+
735
+ // Verify OP3
736
+ expect(json.operators[2]).toEqual({
737
+ id: 3,
738
+ osc: { detune: 0, freq: { coarse: 0, fine: 0, mode: "RATIO" } },
739
+ eg: { rates: [88, 96, 32, 30], levels: [79, 65, 0, 0] },
740
+ key: { velocity: 3, scaling: 6, breakPoint: "A-1" },
741
+ output: { level: 99, ampModSens: 0 },
742
+ scale: { left: { depth: 0, curve: "-LN" }, right: { depth: 0, curve: "-LN" } },
743
+ })
744
+
745
+ // Verify OP4
746
+ expect(json.operators[3]).toEqual({
747
+ id: 4,
748
+ osc: { detune: 0, freq: { coarse: 5, fine: 0, mode: "RATIO" } },
749
+ eg: { rates: [90, 42, 7, 55], levels: [90, 30, 0, 0] },
750
+ key: { velocity: 5, scaling: 5, breakPoint: "A-1" },
751
+ output: { level: 93, ampModSens: 0 },
752
+ scale: { left: { depth: 0, curve: "-LN" }, right: { depth: 0, curve: "-LN" } },
753
+ })
754
+
755
+ // Verify OP5
756
+ expect(json.operators[4]).toEqual({
757
+ id: 5,
758
+ osc: { detune: 0, freq: { coarse: 0, fine: 0, mode: "RATIO" } },
759
+ eg: { rates: [99, 0, 0, 0], levels: [99, 0, 0, 0] },
760
+ key: { velocity: 3, scaling: 7, breakPoint: "C#4" },
761
+ output: { level: 62, ampModSens: 0 },
762
+ scale: { left: { depth: 75, curve: "-LN" }, right: { depth: 0, curve: "-LN" } },
763
+ })
764
+
765
+ // Verify OP6
766
+ expect(json.operators[5]).toEqual({
767
+ id: 6,
768
+ osc: { detune: 0, freq: { coarse: 9, fine: 0, mode: "RATIO" } },
769
+ eg: { rates: [94, 56, 24, 55], levels: [93, 28, 0, 0] },
770
+ key: { velocity: 7, scaling: 1, breakPoint: "A-1" },
771
+ output: { level: 85, ampModSens: 0 },
772
+ scale: { left: { depth: 0, curve: "-LN" }, right: { depth: 0, curve: "-LN" } },
773
+ })
774
+ })
775
+ })
776
+
777
+ describe("bank toJSON", () => {
778
+ it("should convert entire bank to JSON format", () => {
779
+ const json = bank.toJSON()
780
+
781
+ // Verify bank structure
782
+ expect(json.name).toBe("")
783
+ expect(json.voices).toHaveLength(DX7Bank.NUM_VOICES)
784
+
785
+ // Check first few voices have correct structure
786
+ expect(json.voices[0].index).toBe(1)
787
+ expect(json.voices[0].name).toBe("BRASS 1")
788
+ expect(json.voices[0].operators).toHaveLength(6)
789
+ expect(json.voices[0].global).toBeDefined()
790
+ expect(json.voices[0].lfo).toBeDefined()
791
+ expect(json.voices[0].pitchEG).toBeDefined()
792
+
793
+ expect(json.voices[14].index).toBe(15) // BASS 1
794
+ expect(json.voices[14].name).toBe("BASS 1")
795
+
796
+ // All voices should have proper structure
797
+ json.voices.forEach((voice, idx) => {
798
+ expect(voice.index).toBe(idx + 1) // 1-based indexing
799
+ expect(typeof voice.name).toBe("string")
800
+ expect(voice.operators).toHaveLength(6)
801
+ expect(voice.global).toBeDefined()
802
+ expect(voice.lfo).toBeDefined()
803
+ expect(voice.pitchEG).toBeDefined()
804
+ })
805
+ })
806
+
807
+ it("should preserve bank name when set", () => {
808
+ const testBank = new DX7Bank()
809
+ testBank.name = "Test Bank"
810
+ const json = testBank.toJSON()
811
+
812
+ expect(json.name).toBe("Test Bank")
813
+ expect(json.voices).toHaveLength(DX7Bank.NUM_VOICES)
814
+ })
815
+
816
+ it("should include all voice data in JSON", () => {
817
+ const json = bank.toJSON()
818
+
819
+ // Pick a specific voice (BASS 1 at index 14)
820
+ const bass1Json = json.voices[14]
821
+
822
+ // Verify it matches the individual voice JSON
823
+ const bass1Voice = bank.getVoice(14)
824
+ const bass1IndividualJson = bass1Voice.toJSON()
825
+
826
+ expect(bass1Json.name).toBe(bass1IndividualJson.name)
827
+ expect(bass1Json.operators).toEqual(bass1IndividualJson.operators)
828
+ expect(bass1Json.global).toEqual(bass1IndividualJson.global)
829
+ expect(bass1Json.lfo).toEqual(bass1IndividualJson.lfo)
830
+ expect(bass1Json.pitchEG).toEqual(bass1IndividualJson.pitchEG)
831
+ })
832
+
833
+ it("should match structure of fixture JSON files", () => {
834
+ const json = bank.toJSON()
835
+
836
+ // Verify structure matches what we export in fixtures
837
+ expect(json).toHaveProperty("name")
838
+ expect(json).toHaveProperty("voices")
839
+ expect(Array.isArray(json.voices)).toBe(true)
840
+
841
+ // Verify each voice has index field
842
+ json.voices.forEach((voice) => {
843
+ expect(voice).toHaveProperty("index")
844
+ expect(typeof voice.index).toBe("number")
845
+ expect(voice.index).toBeGreaterThanOrEqual(1)
846
+ expect(voice.index).toBeLessThanOrEqual(32)
847
+ })
848
+ })
849
+ })
850
+
851
+ describe("export/import round-trip", () => {
852
+ it("should export BASS 1 and be able to re-import it", async () => {
853
+ // Get BASS 1 from ROM
854
+ const bass1 = bank.getVoice(14)
855
+ expect(bass1.name).toBe("BASS 1")
856
+
857
+ // Export to SysEx single voice format
858
+ const sysex = bass1.toSysEx()
859
+ expect(sysex.length).toBe(DX7Voice.VCED_SIZE)
860
+
861
+ // Load it back using DX7Voice.fromFile (single voice format)
862
+ const blob = new Blob([sysex])
863
+ const file = new File([blob], "BASS_1_EXPORT.syx")
864
+ const loadedVoice = await DX7Voice.fromFile(file)
865
+
866
+ // Verify the round-trip preserved the voice
867
+ expect(loadedVoice.name).toBe("BASS 1")
868
+
869
+ // Compare all packed bytes - they should be identical
870
+ for (let i = 0; i < DX7Voice.PACKED_SIZE; i++) {
871
+ expect(loadedVoice.data[i]).toBe(bass1.data[i])
872
+ }
873
+ })
874
+
875
+ it("should maintain voice integrity through complete round-trip", async () => {
876
+ // Get BASS 1 from ROM
877
+ const originalBass1 = bank.getVoice(14)
878
+
879
+ // Export to SysEx
880
+ const sysexData = originalBass1.toSysEx()
881
+
882
+ // Create a File object from the SysEx (simulating browser file upload)
883
+ const blob = new Blob([sysexData])
884
+ const file = new File([blob], "BASS_1_EXPORT.syx")
885
+
886
+ // Load it back using DX7Voice.fromFile (single voice format)
887
+ const loadedVoice = await DX7Voice.fromFile(file)
888
+
889
+ // Verify the round-trip preserved the voice
890
+ expect(loadedVoice.name).toBe("BASS 1")
891
+
892
+ // Compare unpacked parameters to verify no data loss
893
+ const originalUnpacked = originalBass1.unpack()
894
+ const loadedUnpacked = loadedVoice.unpack()
895
+
896
+ for (let i = 0; i < DX7Voice.UNPACKED_SIZE; i++) {
897
+ expect(loadedUnpacked[i]).toBe(originalUnpacked[i])
898
+ }
899
+ })
900
+ })
901
+
902
+ describe("toSysEx", () => {
903
+ it("should create correct single voice dump format", () => {
904
+ // Get voice 14 which is BASS 1 (based on ROM voice list)
905
+ const bassPatch = bank.getVoice(14)
906
+ expect(bassPatch).not.toBeNull()
907
+ expect(bassPatch.name).toBe("BASS 1")
908
+
909
+ const sysex = bassPatch.toSysEx()
910
+
911
+ // Verify total size (163 bytes)
912
+ expect(sysex.length).toBe(DX7Voice.VCED_SIZE)
913
+
914
+ // Verify header bytes
915
+ expect(sysex[0]).toBe(DX7Bank.SYSEX_START) // SysEx start
916
+ expect(sysex[1]).toBe(DX7Bank.SYSEX_YAMAHA_ID) // Yamaha ID
917
+ expect(sysex[2]).toBe(0x00) // Channel/format (nibblized)
918
+ expect(sysex[3]).toBe(0x00) // Substatus: bulk dump
919
+ expect(sysex[4]).toBe(0x01) // Format: single voice dump
920
+ expect(sysex[5]).toBe(0x1b) // Packet type
921
+
922
+ // Verify voice data (145 bytes of unpacked parameters)
923
+ const voiceData = sysex.slice(6, 151)
924
+ expect(voiceData.length).toBe(145)
925
+
926
+ // Voice name: 10 bytes at offset 151-160
927
+ const nameBytes = sysex.slice(151, 161)
928
+ const extractedName = String.fromCharCode(
929
+ ...nameBytes.map((b) => b & DX7Voice.MASK_7BIT),
930
+ ).trim()
931
+ expect(extractedName).toBe("BASS 1")
932
+
933
+ // Verify checksum (byte 161)
934
+ const checksum = sysex[161]
935
+ expect(checksum).toBeGreaterThanOrEqual(0)
936
+ expect(checksum).toBeLessThanOrEqual(127)
937
+
938
+ // Verify SysEx end byte
939
+ expect(sysex[DX7Voice.VCED_SIZE - 1]).toBe(DX7Voice.VCED_SYSEX_END)
940
+
941
+ // Verify checksum is correct
942
+ // Sum all 155 data bytes (bytes 6-160)
943
+ let sum = 0
944
+ for (let i = 6; i < 161; i++) {
945
+ sum += sysex[i]
946
+ }
947
+ const expectedChecksum =
948
+ (DX7Bank.CHECKSUM_MODULO - (sum % DX7Bank.CHECKSUM_MODULO)) & DX7Voice.MASK_7BIT
949
+ expect(checksum).toBe(expectedChecksum)
950
+ })
951
+
952
+ it("should produce consistent output for same voice", () => {
953
+ const bassPatch = bank.getVoice(14)
954
+ const sysex1 = bassPatch.toSysEx()
955
+ const sysex2 = bassPatch.toSysEx()
956
+
957
+ // Both outputs should be identical
958
+ expect(sysex1.length).toBe(sysex2.length)
959
+ for (let i = 0; i < sysex1.length; i++) {
960
+ expect(sysex1[i]).toBe(sysex2[i])
961
+ }
962
+ })
963
+
964
+ it("should have correct size for single voice dump format", () => {
965
+ const voice = bank.getVoice(0)
966
+ const sysex = voice.toSysEx()
967
+
968
+ // Single voice dump format:
969
+ // - 6 bytes header
970
+ // - 145 bytes voice data (unpacked format)
971
+ // - 10 bytes voice name
972
+ // - 1 byte checksum
973
+ // - 1 byte F7 footer
974
+ // Total: 163 bytes
975
+ expect(sysex.length).toBe(DX7Voice.VCED_SIZE)
976
+ })
977
+
978
+ it("should validate checksum calculation", () => {
979
+ const voice = bank.getVoice(0)
980
+ const sysex = voice.toSysEx()
981
+
982
+ // Manually calculate checksum
983
+ let calculatedSum = 0
984
+ for (let i = 6; i < 161; i++) {
985
+ calculatedSum += sysex[i]
986
+ }
987
+ const calculatedChecksum =
988
+ (DX7Bank.CHECKSUM_MODULO - (calculatedSum % DX7Bank.CHECKSUM_MODULO)) & DX7Voice.MASK_7BIT
989
+
990
+ // Compare with actual checksum in the SysEx (at index 161, before F7 at 162)
991
+ expect(sysex[161]).toBe(calculatedChecksum)
992
+ expect(sysex[DX7Voice.VCED_SIZE - 1]).toBe(DX7Voice.VCED_SYSEX_END) // Verify F7 is at the correct position
993
+ })
994
+ })
995
+ })
996
+
997
+ describe("Real DX7 Single Voice File (ROM1A_BASS____1.syx)", () => {
998
+ let bank
999
+ let voice
1000
+ let romBank
1001
+ let _romBass1
1002
+
1003
+ beforeAll(async () => {
1004
+ // Load the single voice file from fixtures directory using DX7Voice.fromFile
1005
+ const fs = await import("node:fs")
1006
+ const path = await import("node:path")
1007
+
1008
+ const fixturesPath = path.join(__dirname, "../../fixtures/ROM1A_BASS____1.syx")
1009
+ const data = fs.readFileSync(fixturesPath)
1010
+ const blob = new Blob([data])
1011
+ const file = new File([blob], "ROM1A_BASS____1.syx")
1012
+ voice = await DX7Voice.fromFile(file)
1013
+
1014
+ // Create a bank with the single voice for API compatibility
1015
+ bank = new DX7Bank()
1016
+ bank.voices.fill(DX7Voice.createDefault())
1017
+ bank.voices[0] = voice
1018
+ bank.name = "ROM1A_BASS____1.syx"
1019
+
1020
+ // Load the full ROM bank for comparison
1021
+ const romPath = path.join(__dirname, "../../fixtures/ROM1A.syx")
1022
+ const romData = fs.readFileSync(romPath)
1023
+ romBank = new DX7Bank(romData)
1024
+ _romBass1 = romBank.getVoice(14) // BASS 1 is at index 14 in ROM1A
1025
+ })
1026
+
1027
+ describe("basic file properties", () => {
1028
+ it("should load as a bank with one voice", () => {
1029
+ expect(bank.voices.length).toBe(DX7Bank.NUM_VOICES)
1030
+ expect(voice.name).toBe("BASS 1")
1031
+ })
1032
+
1033
+ it("should have valid voice parameters", () => {
1034
+ expect(voice).not.toBeNull()
1035
+ expect(voice.data.length).toBe(DX7Voice.PACKED_SIZE)
1036
+
1037
+ // All parameters should be in valid range (0-127)
1038
+ for (let i = 0; i < DX7Voice.PACKED_SIZE; i++) {
1039
+ const param = voice.getParameter(i)
1040
+ expect(param).toBeGreaterThanOrEqual(0)
1041
+ expect(param).toBeLessThanOrEqual(127)
1042
+ }
1043
+ })
1044
+
1045
+ it("should have correct voice name", () => {
1046
+ expect(voice.name).toBe("BASS 1")
1047
+ })
1048
+ })
1049
+
1050
+ describe("single voice conversion", () => {
1051
+ it("should convert to SysEx single voice format", () => {
1052
+ const sysex = voice.toSysEx()
1053
+
1054
+ expect(sysex.length).toBe(DX7Voice.VCED_SIZE)
1055
+ expect(sysex[0]).toBe(DX7Voice.VCED_SYSEX_START)
1056
+ expect(sysex[1]).toBe(DX7Voice.VCED_YAMAHA_ID)
1057
+ expect(sysex[2]).toBe(DX7Voice.VCED_SUB_STATUS)
1058
+ expect(sysex[3]).toBe(DX7Voice.VCED_FORMAT_SINGLE)
1059
+ expect(sysex[DX7Voice.VCED_SIZE - 1]).toBe(DX7Voice.VCED_SYSEX_END)
1060
+ })
1061
+
1062
+ it("should maintain data integrity through toSysEx conversion", () => {
1063
+ const sysex = voice.toSysEx()
1064
+
1065
+ // Verify the SysEx structure
1066
+ expect(sysex.length).toBe(DX7Voice.VCED_SIZE)
1067
+
1068
+ // Verify the voice data section (bytes 6-160) contains valid parameters
1069
+ const voiceDataSection = sysex.slice(6, 161)
1070
+ expect(voiceDataSection.length).toBe(155)
1071
+
1072
+ // Each byte should be valid 7-bit MIDI data
1073
+ for (let i = 0; i < voiceDataSection.length; i++) {
1074
+ expect(voiceDataSection[i]).toBeGreaterThanOrEqual(0)
1075
+ expect(voiceDataSection[i]).toBeLessThanOrEqual(127)
1076
+ }
1077
+ })
1078
+
1079
+ it("should create valid checksum", () => {
1080
+ const sysex = voice.toSysEx()
1081
+ const checksum = sysex[161]
1082
+
1083
+ // Manually calculate checksum
1084
+ let sum = 0
1085
+ for (let i = 6; i < 161; i++) {
1086
+ sum += sysex[i]
1087
+ }
1088
+ const expectedChecksum =
1089
+ (DX7Bank.CHECKSUM_MODULO - (sum % DX7Bank.CHECKSUM_MODULO)) & DX7Voice.MASK_7BIT
1090
+
1091
+ expect(checksum).toBe(expectedChecksum)
1092
+ })
1093
+ })
1094
+
1095
+ describe("single voice pack/unpack", () => {
1096
+ it("should unpack and repack correctly", () => {
1097
+ const unpacked = voice.unpack()
1098
+ const repacked = DX7Voice.pack(unpacked)
1099
+ const newVoice = new DX7Voice(repacked, 0)
1100
+
1101
+ expect(newVoice.name).toBe(voice.name)
1102
+
1103
+ // Compare all packed bytes
1104
+ for (let i = 0; i < DX7Voice.PACKED_SIZE; i++) {
1105
+ expect(newVoice.data[i]).toBe(voice.data[i] & DX7Voice.MASK_7BIT)
1106
+ }
1107
+ })
1108
+
1109
+ it("should produce correct JSON representation", () => {
1110
+ const json = voice.toJSON()
1111
+ expect(json.name).toBe("BASS 1")
1112
+ expect(json.operators).toHaveLength(6)
1113
+ expect(json.global.algorithm).toBe(16)
1114
+ expect(json.global.feedback).toBe(7)
1115
+ })
1116
+ })
1117
+
1118
+ describe("edge cases for single voice files", () => {
1119
+ it("should reject single voice files via DX7Bank.fromFile", async () => {
1120
+ const fs = await import("node:fs")
1121
+ const path = await import("node:path")
1122
+
1123
+ const fixturesPath = path.join(__dirname, "../../fixtures/ROM1A_BASS____1.syx")
1124
+ const data = fs.readFileSync(fixturesPath)
1125
+ const blob = new Blob([data])
1126
+ const file = new File([blob], "ROM1A_BASS____1.syx")
1127
+
1128
+ // DX7Bank.fromFile should reject single voice files
1129
+ await expect(DX7Bank.fromFile(file)).rejects.toThrow(
1130
+ "This is a single voice file. Use DX7Voice.fromFile() instead.",
1131
+ )
1132
+ })
1133
+
1134
+ it("should handle single voice in voice array correctly", () => {
1135
+ // Single voice should be in slot 0
1136
+ expect(voice.index).toBe(0)
1137
+
1138
+ // All voices should be valid
1139
+ for (let i = 0; i < DX7Bank.NUM_VOICES; i++) {
1140
+ const v = bank.getVoice(i)
1141
+ expect(v).not.toBeNull()
1142
+ expect(v.data.length).toBe(DX7Voice.PACKED_SIZE)
1143
+ }
1144
+ })
1145
+
1146
+ it("should match ALL parameter values exactly", async () => {
1147
+ // Load the bank (ROM1A.syx) to get BASS 1 from slot 15 (index 14)
1148
+ const bankBass1 = romBank.getVoice(14) // BASS 1 in ROM1A
1149
+ const bankJson = bankBass1.toJSON()
1150
+
1151
+ // Load the single voice file (ROM1A_BASS____1.syx)
1152
+ const singleVoiceJson = voice.toJSON()
1153
+
1154
+ // Verify all operator parameters match
1155
+ for (let i = 0; i < DX7Voice.NUM_OPERATORS; i++) {
1156
+ const singleOp = singleVoiceJson.operators[i]
1157
+ const bankOp = bankJson.operators[i]
1158
+
1159
+ // OSC parameters
1160
+ expect(singleOp.osc.detune).toBe(bankOp.osc.detune)
1161
+ expect(singleOp.osc.freq.coarse).toBe(bankOp.osc.freq.coarse)
1162
+ expect(singleOp.osc.freq.fine).toBe(bankOp.osc.freq.fine)
1163
+ expect(singleOp.osc.freq.mode).toBe(bankOp.osc.freq.mode)
1164
+
1165
+ // EG rates and levels
1166
+ expect(singleOp.eg.rates).toEqual(bankOp.eg.rates)
1167
+ expect(singleOp.eg.levels).toEqual(bankOp.eg.levels)
1168
+
1169
+ // KEY parameters (including velocity)
1170
+ expect(singleOp.key.velocity).toBe(bankOp.key.velocity)
1171
+ expect(singleOp.key.scaling).toBe(bankOp.key.scaling)
1172
+ expect(singleOp.key.breakPoint).toBe(bankOp.key.breakPoint)
1173
+
1174
+ // OUTPUT parameters
1175
+ expect(singleOp.output.level).toBe(bankOp.output.level)
1176
+ expect(singleOp.output.ampModSens).toBe(bankOp.output.ampModSens)
1177
+
1178
+ // SCALE parameters
1179
+ expect(singleOp.scale.left.depth).toBe(bankOp.scale.left.depth)
1180
+ expect(singleOp.scale.left.curve).toBe(bankOp.scale.left.curve)
1181
+ expect(singleOp.scale.right.depth).toBe(bankOp.scale.right.depth)
1182
+ expect(singleOp.scale.right.curve).toBe(bankOp.scale.right.curve)
1183
+ }
1184
+
1185
+ // Pitch EG
1186
+ expect(singleVoiceJson.pitchEG.rates).toEqual(bankJson.pitchEG.rates)
1187
+ expect(singleVoiceJson.pitchEG.levels).toEqual(bankJson.pitchEG.levels)
1188
+
1189
+ // LFO parameters
1190
+ expect(singleVoiceJson.lfo.speed).toBe(bankJson.lfo.speed)
1191
+ expect(singleVoiceJson.lfo.delay).toBe(bankJson.lfo.delay)
1192
+ expect(singleVoiceJson.lfo.pmDepth).toBe(bankJson.lfo.pmDepth)
1193
+ expect(singleVoiceJson.lfo.amDepth).toBe(bankJson.lfo.amDepth)
1194
+ expect(singleVoiceJson.lfo.keySync).toBe(bankJson.lfo.keySync)
1195
+ expect(singleVoiceJson.lfo.wave).toBe(bankJson.lfo.wave)
1196
+
1197
+ // Global parameters
1198
+ expect(singleVoiceJson.global.algorithm).toBe(bankJson.global.algorithm)
1199
+ expect(singleVoiceJson.global.feedback).toBe(bankJson.global.feedback)
1200
+ expect(singleVoiceJson.global.oscKeySync).toBe(bankJson.global.oscKeySync)
1201
+ expect(singleVoiceJson.global.pitchModSens).toBe(bankJson.global.pitchModSens)
1202
+ expect(singleVoiceJson.global.transpose).toBe(bankJson.global.transpose)
1203
+
1204
+ // Name should match
1205
+ expect(singleVoiceJson.name).toBe(bankJson.name)
1206
+ })
1207
+ })
1208
+ })