spessasynth_lib 3.23.13 → 3.24.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.
@@ -21,6 +21,7 @@ export class MidiData extends MIDISequenceData {
21
21
  copyright: any;
22
22
  tracksAmount: any;
23
23
  lyrics: any;
24
+ lyricsTicks: any;
24
25
  firstNoteOn: any;
25
26
  keyRange: any;
26
27
  lastVoiceEventTick: any;
@@ -35,6 +36,7 @@ export class MidiData extends MIDISequenceData {
35
36
  format: any;
36
37
  RMIDInfo: any;
37
38
  bankOffset: any;
39
+ isKaraokeFile: any;
38
40
  }
39
41
  /**
40
42
  * Temporary MIDI data used when the MIDI is not loaded.
@@ -40,6 +40,11 @@ export class MIDISequenceData {
40
40
  * @type {Uint8Array[]}
41
41
  */
42
42
  lyrics: Uint8Array[];
43
+ /**
44
+ * An array of tick positions where lyrics events occur in the sequence.
45
+ * @type {number[]}
46
+ */
47
+ lyricsTicks: number[];
43
48
  /**
44
49
  * The tick position of the first note-on event in the MIDI sequence.
45
50
  * @type {number}
@@ -121,4 +126,10 @@ export class MIDISequenceData {
121
126
  * @type {number}
122
127
  */
123
128
  bankOffset: number;
129
+ /**
130
+ * If the MIDI file is a Soft Karaoke file (.kar), this flag is set to true.
131
+ * https://www.mixagesoftware.com/en/midikit/help/HTML/karaoke_formats.html
132
+ * @type {boolean}
133
+ */
134
+ isKaraokeFile: boolean;
124
135
  }
@@ -3,62 +3,68 @@
3
3
  * purpose: manages the synthesizer's event system, calling assinged functions when synthesizer requests dispatching the event
4
4
  */
5
5
  /**
6
- *
7
- * @typedef {{
8
- * midiNote: number,
9
- * channel: number,
10
- * velocity: number
11
- * }} NoteOnCallback
12
- *
13
- * @typedef {{
14
- * midiNote: number,
15
- * channel: number
16
- * }} NoteOffCallback
17
- *
18
- * @typedef {{
19
- * channel: number,
20
- * isDrumChannel: boolean
21
- * }} DrumChangeCallback
22
- *
23
- * @typedef {{
24
- * channel: number,
25
- * program: number,
26
- * bank: number,
27
- * userCalled: boolean
28
- * }} ProgramChangeCallback
29
- *
30
- * @typedef {{
31
- * channel: number,
32
- * controllerNumber: number,
33
- * controllerValue: number
34
- * }} ControllerChangeCallback
35
- *
36
- * @typedef {{
37
- * channel:number,
38
- * isMuted: boolean
39
- * }} MuteChannelCallback
40
- *
41
- * @typedef {{
42
- * presetName: string,
43
- * bank: number,
44
- * program: number
45
- * }[]} PresetListChangeCallback
46
- *
47
- *
48
- * @typedef {{
49
- * channel: number,
50
- * MSB: number,
51
- * LSB: number
52
- * }} PitchWheelCallback
53
- *
54
- * @typedef {{
55
- * channel: number,
56
- * pressure: number
57
- * }} ChannelPressureCallback
58
- *
59
- * @typedef {string} SoundfontErrorCallback
60
- *
61
- *
6
+ * @typedef {Object} NoteOnCallback
7
+ * @property {number} midiNote - The MIDI note number.
8
+ * @property {number} channel - The MIDI channel number.
9
+ * @property {number} velocity - The velocity of the note.
10
+ */
11
+ /**
12
+ * @typedef {Object} NoteOffCallback
13
+ * @property {number} midiNote - The MIDI note number.
14
+ * @property {number} channel - The MIDI channel number.
15
+ */
16
+ /**
17
+ * @typedef {Object} DrumChangeCallback
18
+ * @property {number} channel - The MIDI channel number.
19
+ * @property {boolean} isDrumChannel - Indicates if the channel is a drum channel.
20
+ */
21
+ /**
22
+ * @typedef {Object} ProgramChangeCallback
23
+ * @property {number} channel - The MIDI channel number.
24
+ * @property {number} program - The program number.
25
+ * @property {number} bank - The bank number.
26
+ * @property {boolean} userCalled - Indicates if the change was user-initiated.
27
+ */
28
+ /**
29
+ * @typedef {Object} ControllerChangeCallback
30
+ * @property {number} channel - The MIDI channel number.
31
+ * @property {number} controllerNumber - The controller number.
32
+ * @property {number} controllerValue - The value of the controller.
33
+ */
34
+ /**
35
+ * @typedef {Object} MuteChannelCallback
36
+ * @property {number} channel - The MIDI channel number.
37
+ * @property {boolean} isMuted - Indicates if the channel is muted.
38
+ */
39
+ /**
40
+ * @typedef {Object} PresetListChangeCallbackSingle
41
+ * @property {string} presetName - The name of the preset.
42
+ * @property {number} bank - The bank number.
43
+ * @property {number} program - The program number.
44
+ */
45
+ /**
46
+ * @typedef {PresetListChangeCallbackSingle[]} PresetListChangeCallback - A list of preset objects.
47
+ */
48
+ /**
49
+ * @typedef {Object} SynthDisplayCallback
50
+ * @property {Uint8Array} displayData - The data to display.
51
+ * @property {SynthDisplayType} displayType - The type of display.
52
+ */
53
+ /**
54
+ * @typedef {Object} PitchWheelCallback
55
+ * @property {number} channel - The MIDI channel number.
56
+ * @property {number} MSB - The most significant byte of the pitch wheel value.
57
+ * @property {number} LSB - The least significant byte of the pitch wheel value.
58
+ */
59
+ /**
60
+ * @typedef {Object} ChannelPressureCallback
61
+ * @property {number} channel - The MIDI channel number.
62
+ * @property {number} pressure - The pressure value.
63
+ */
64
+ /**
65
+ * @typedef {Error} SoundfontErrorCallback - The error message for soundfont errors.
66
+ */
67
+ /**
62
68
  * @typedef {
63
69
  * NoteOnCallback |
64
70
  * NoteOffCallback |
@@ -70,6 +76,7 @@
70
76
  * PitchWheelCallback |
71
77
  * SoundfontErrorCallback |
72
78
  * ChannelPressureCallback |
79
+ * SynthDisplayCallback |
73
80
  * undefined
74
81
  * } EventCallbackData
75
82
  */
@@ -88,7 +95,8 @@
88
95
  * "mutechannel"|
89
96
  * "presetlistchange"|
90
97
  * "allcontrollerreset"|
91
- * "soundfonterror"} EventTypes
98
+ * "soundfonterror"|
99
+ * "synthdisplay"} EventTypes
92
100
  */
93
101
  export class EventHandler {
94
102
  /**
@@ -122,45 +130,134 @@ export class EventHandler {
122
130
  callEvent(name: EventTypes, eventData: EventCallbackData): void;
123
131
  }
124
132
  export type NoteOnCallback = {
133
+ /**
134
+ * - The MIDI note number.
135
+ */
125
136
  midiNote: number;
137
+ /**
138
+ * - The MIDI channel number.
139
+ */
126
140
  channel: number;
141
+ /**
142
+ * - The velocity of the note.
143
+ */
127
144
  velocity: number;
128
145
  };
129
146
  export type NoteOffCallback = {
147
+ /**
148
+ * - The MIDI note number.
149
+ */
130
150
  midiNote: number;
151
+ /**
152
+ * - The MIDI channel number.
153
+ */
131
154
  channel: number;
132
155
  };
133
156
  export type DrumChangeCallback = {
157
+ /**
158
+ * - The MIDI channel number.
159
+ */
134
160
  channel: number;
161
+ /**
162
+ * - Indicates if the channel is a drum channel.
163
+ */
135
164
  isDrumChannel: boolean;
136
165
  };
137
166
  export type ProgramChangeCallback = {
167
+ /**
168
+ * - The MIDI channel number.
169
+ */
138
170
  channel: number;
171
+ /**
172
+ * - The program number.
173
+ */
139
174
  program: number;
175
+ /**
176
+ * - The bank number.
177
+ */
140
178
  bank: number;
179
+ /**
180
+ * - Indicates if the change was user-initiated.
181
+ */
141
182
  userCalled: boolean;
142
183
  };
143
184
  export type ControllerChangeCallback = {
185
+ /**
186
+ * - The MIDI channel number.
187
+ */
144
188
  channel: number;
189
+ /**
190
+ * - The controller number.
191
+ */
145
192
  controllerNumber: number;
193
+ /**
194
+ * - The value of the controller.
195
+ */
146
196
  controllerValue: number;
147
197
  };
148
198
  export type MuteChannelCallback = {
199
+ /**
200
+ * - The MIDI channel number.
201
+ */
149
202
  channel: number;
203
+ /**
204
+ * - Indicates if the channel is muted.
205
+ */
150
206
  isMuted: boolean;
151
207
  };
152
- export type PresetListChangeCallback = {
208
+ export type PresetListChangeCallbackSingle = {
209
+ /**
210
+ * - The name of the preset.
211
+ */
153
212
  presetName: string;
213
+ /**
214
+ * - The bank number.
215
+ */
154
216
  bank: number;
217
+ /**
218
+ * - The program number.
219
+ */
155
220
  program: number;
156
- }[];
221
+ };
222
+ /**
223
+ * - A list of preset objects.
224
+ */
225
+ export type PresetListChangeCallback = PresetListChangeCallbackSingle[];
226
+ export type SynthDisplayCallback = {
227
+ /**
228
+ * - The data to display.
229
+ */
230
+ displayData: Uint8Array;
231
+ /**
232
+ * - The type of display.
233
+ */
234
+ displayType: SynthDisplayType;
235
+ };
157
236
  export type PitchWheelCallback = {
237
+ /**
238
+ * - The MIDI channel number.
239
+ */
158
240
  channel: number;
241
+ /**
242
+ * - The most significant byte of the pitch wheel value.
243
+ */
159
244
  MSB: number;
245
+ /**
246
+ * - The least significant byte of the pitch wheel value.
247
+ */
160
248
  LSB: number;
161
249
  };
162
250
  export type ChannelPressureCallback = {
251
+ /**
252
+ * - The MIDI channel number.
253
+ */
163
254
  channel: number;
255
+ /**
256
+ * - The pressure value.
257
+ */
164
258
  pressure: number;
165
259
  };
166
- export type SoundfontErrorCallback = string;
260
+ /**
261
+ * - The error message for soundfont errors.
262
+ */
263
+ export type SoundfontErrorCallback = Error;
@@ -23,6 +23,11 @@ export function formatTitle(fileName: string): string;
23
23
  * @returns {string}
24
24
  */
25
25
  export function arrayToHexString(arr: number[]): string;
26
+ /**
27
+ * @param eventData {Uint8Array}
28
+ * @returns {Uint8Array}
29
+ */
30
+ export function sanitizeKarLyrics(eventData: Uint8Array): Uint8Array;
26
31
  export namespace consoleColors {
27
32
  let warn: string;
28
33
  let unrecognized: string;
package/README.md CHANGED
@@ -82,6 +82,7 @@ document.getElementById("button").onclick = async () =>
82
82
  - **Easy MIDI editing:** Use [helper functions](https://github.com/spessasus/SpessaSynth/wiki/Writing-MIDI-Files#modifymidi) to modify the song to your needs!
83
83
  - **Loop detection:** Automatically detects loops in MIDIs (e.g., from _Touhou Project_)
84
84
  - **First note detection:** Skip unnecessary silence at the start by jumping to the first note!
85
+ - **Lyrics support:** Both regular MIDI and .kar files!
85
86
  - **[Write MIDI files from scratch](https://github.com/spessasus/SpessaSynth/wiki/Creating-MIDI-Files)**
86
87
  - **Easy saving:** Save with just [one function!](https://github.com/spessasus/SpessaSynth/wiki/Writing-MIDI-Files#writemidifile)
87
88
 
@@ -122,7 +123,6 @@ document.getElementById("button").onclick = async () =>
122
123
  - **Loop multiple times:** *Render two (or more) loops into the file for seamless transitions!*
123
124
  - *That's right, saving as WAV is also [just one function!](https://github.com/spessasus/SpessaSynth/wiki/Writing-Wave-Files#audiobuffertowav)*
124
125
 
125
-
126
126
  # License
127
127
 
128
128
  MIT License, except for the stbvorbis_sync.js in the `externals` folder which is licensed under the Apache-2.0 license.
@@ -45,10 +45,12 @@ export class BasicMIDI extends MIDISequenceData
45
45
  m.loop = { ...mid.loop }; // Deep copy of loop
46
46
  m.format = mid.format;
47
47
  m.bankOffset = mid.bankOffset;
48
+ m.isKaraokeFile = mid.isKaraokeFile;
48
49
 
49
50
  // Copying arrays
50
51
  m.tempoChanges = [...mid.tempoChanges]; // Shallow copy
51
52
  m.lyrics = mid.lyrics.map(arr => new Uint8Array(arr)); // Deep copy of each binary chunk
53
+ m.lyricsTicks = [...mid.lyricsTicks]; // Shallow copy
52
54
  m.midiPorts = [...mid.midiPorts]; // Shallow copy
53
55
  m.midiPortChannelOffsets = [...mid.midiPortChannelOffsets]; // Shallow copy
54
56
  m.usedChannelsOnTrack = mid.usedChannelsOnTrack.map(set => new Set(set)); // Deep copy
@@ -28,6 +28,7 @@ export class MidiData extends MIDISequenceData
28
28
  this.copyright = midi.copyright;
29
29
  this.tracksAmount = midi.tracksAmount;
30
30
  this.lyrics = midi.lyrics;
31
+ this.lyricsTicks = midi.lyricsTicks;
31
32
  this.firstNoteOn = midi.firstNoteOn;
32
33
  this.keyRange = midi.keyRange;
33
34
  this.lastVoiceEventTick = midi.lastVoiceEventTick;
@@ -42,6 +43,7 @@ export class MidiData extends MIDISequenceData
42
43
  this.format = midi.format;
43
44
  this.RMIDInfo = midi.RMIDInfo;
44
45
  this.bankOffset = midi.bankOffset;
46
+ this.isKaraokeFile = midi.isKaraokeFile;
45
47
 
46
48
  // Set isEmbedded based on the presence of an embeddedSoundFont
47
49
  this.isEmbedded = midi.embeddedSoundFont !== undefined;
@@ -1,18 +1,15 @@
1
1
  import { dataBytesAmount, getChannel, messageTypes, MidiMessage } from "./midi_message.js";
2
2
  import { IndexedByteArray } from "../utils/indexed_array.js";
3
- import { consoleColors, formatTitle } from "../utils/other.js";
3
+ import { consoleColors, formatTitle, sanitizeKarLyrics } from "../utils/other.js";
4
4
  import { SpessaSynthGroupCollapsed, SpessaSynthGroupEnd, SpessaSynthInfo, SpessaSynthWarn } from "../utils/loggin.js";
5
5
  import { readRIFFChunk } from "../soundfont/basic_soundfont/riff_chunk.js";
6
6
  import { readVariableLengthQuantity } from "../utils/byte_functions/variable_length_quantity.js";
7
7
  import { readBytesAsUintBigEndian } from "../utils/byte_functions/big_endian.js";
8
- import { readBytesAsString } from "../utils/byte_functions/string.js";
8
+ import { getStringBytes, readBytesAsString } from "../utils/byte_functions/string.js";
9
9
  import { readLittleEndian } from "../utils/byte_functions/little_endian.js";
10
10
  import { RMIDINFOChunks } from "./rmidi_writer.js";
11
11
  import { BasicMIDI, MIDIticksToSeconds } from "./basic_midi.js";
12
12
 
13
-
14
- const GS_TEXT_HEADER = new Uint8Array([0x41, 0x10, 0x45, 0x12, 0x10, 0x00, 0x00]);
15
-
16
13
  /**
17
14
  * midi_loader.js
18
15
  * purpose: parses a midi file for the seqyencer, including things like marker or CC 2/4 loop detection, copyright detection etc.
@@ -43,6 +40,12 @@ class MIDI extends BasicMIDI
43
40
 
44
41
  let DLSRMID = false;
45
42
 
43
+ /**
44
+ * Will be joined with "\n" to form the final string
45
+ * @type {string[]}
46
+ */
47
+ let copyrightComponents = [];
48
+
46
49
  const initialString = readBytesAsString(binaryData, 4);
47
50
  binaryData.currentIndex -= 4;
48
51
  if (initialString === "RIFF")
@@ -102,6 +105,7 @@ class MIDI extends BasicMIDI
102
105
  }
103
106
  if (this.RMIDInfo["ICOP"])
104
107
  {
108
+ // special case, overwrites the copyright components array
105
109
  copyrightDetected = true;
106
110
  this.copyright = readBytesAsString(
107
111
  this.RMIDInfo["ICOP"],
@@ -193,6 +197,13 @@ class MIDI extends BasicMIDI
193
197
  let loopStart = null;
194
198
  let loopEnd = null;
195
199
 
200
+ /**
201
+ * For karaoke files, text events starting with @T are considered titles
202
+ * usually the first one is the title, and the latter are things such as "sequenced by" etc.
203
+ * @type {boolean}
204
+ */
205
+ let karaokeHasTitle = false;
206
+
196
207
  this.lastVoiceEventTick = 0;
197
208
 
198
209
  /**
@@ -235,6 +246,8 @@ class MIDI extends BasicMIDI
235
246
  throw new SyntaxError(`Invalid track header! Expected "MTrk" got "${trackChunk.type}"`);
236
247
  }
237
248
 
249
+ let trackHasVoiceMessages = false;
250
+
238
251
  /**
239
252
  * MIDI running byte
240
253
  * @type {number}
@@ -332,6 +345,7 @@ class MIDI extends BasicMIDI
332
345
  {
333
346
  case -2:
334
347
  // since this is a meta message
348
+ const eventText = readBytesAsString(eventData, eventData.length);
335
349
  switch (statusByte)
336
350
  {
337
351
  case messageTypes.setTempo:
@@ -344,7 +358,7 @@ class MIDI extends BasicMIDI
344
358
 
345
359
  case messageTypes.marker:
346
360
  // check for loop markers
347
- const text = readBytesAsString(eventData, eventData.length).trim().toLowerCase();
361
+ const text = eventText.trim().toLowerCase();
348
362
  switch (text)
349
363
  {
350
364
  default:
@@ -374,44 +388,95 @@ class MIDI extends BasicMIDI
374
388
  case messageTypes.copyright:
375
389
  if (!copyrightDetected)
376
390
  {
377
- this.copyright += readBytesAsString(
391
+
392
+ eventData.currentIndex = 0;
393
+ copyrightComponents.push(readBytesAsString(
378
394
  eventData,
379
395
  eventData.length,
380
396
  undefined,
381
397
  false
382
- ) + "\n";
398
+ ));
383
399
  }
384
400
  break;
385
401
 
386
402
  case messageTypes.lyric:
387
- this.lyrics.push(eventData);
403
+
404
+ // note here: .kar files sometimes just use...
405
+ // lyrics instead of text because why not (of course)
406
+ // perform the same check for @KMIDI KARAOKE FILE
407
+ if (eventText.trim() === "@KMIDI KARAOKE FILE")
408
+ {
409
+ this.isKaraokeFile = true;
410
+ SpessaSynthInfo("%cKaraoke MIDI detected!", consoleColors.recognized);
411
+ }
412
+
413
+ if (this.isKaraokeFile)
414
+ {
415
+ // replace the type of the message with text
416
+ message.messageStatusByte = messageTypes.text;
417
+ statusByte = messageTypes.text;
418
+ }
419
+ else
420
+ {
421
+ // add lyrics like a regular midi file
422
+ this.lyrics.push(eventData);
423
+ this.lyricsTicks.push(totalTicks);
424
+ break;
425
+ }
426
+
427
+ // kar: treat the same as text
428
+ // fallthrough
429
+ case messageTypes.text:
430
+ // possibly Soft Karaoke MIDI file
431
+ // it has a text event at the start of the file
432
+ // "@KMIDI KARAOKE FILE"
433
+ const checkedText = eventText.trim();
434
+ if (checkedText === "@KMIDI KARAOKE FILE")
435
+ {
436
+ this.isKaraokeFile = true;
437
+
438
+ SpessaSynthInfo("%cKaraoke MIDI detected!", consoleColors.recognized);
439
+ }
440
+ else if (this.isKaraokeFile)
441
+ {
442
+ // check for @T (title)
443
+ // or @A because it is a title too sometimes??? idk it's weird
444
+ if (checkedText.startsWith("@T") || checkedText.startsWith("@A"))
445
+ {
446
+ if (!karaokeHasTitle)
447
+ {
448
+ this.midiName = checkedText.substring(2).trim();
449
+ karaokeHasTitle = true;
450
+ nameDetected = true;
451
+ // encode to rawMidiName
452
+ this.rawMidiName = getStringBytes(this.midiName);
453
+ }
454
+ else
455
+ {
456
+ // append to copyright
457
+ copyrightComponents.push(checkedText.substring(2).trim());
458
+ }
459
+ }
460
+ else if (checkedText[0] !== "@")
461
+ {
462
+ // non @: the lyrics
463
+ this.lyrics.push(sanitizeKarLyrics(eventData));
464
+ this.lyricsTicks.push(totalTicks);
465
+ }
466
+ }
467
+ break;
388
468
  }
389
469
  break;
390
470
 
391
471
  case -3:
392
- // since this is a sysex message
393
- // check for embedded copyright (roland SC display sysex) http://www.bandtrax.com.au/sysex.htm
394
- // header goes like this: 41 10 45 12 10 00 00
395
- if (eventData.slice(0, 7).every((n, i) => GS_TEXT_HEADER[i] === n))
396
- {
397
- /**
398
- * @type {IndexedByteArray}
399
- */
400
- const cutText = eventData.slice(7, messageData.length - 3);
401
- const decoded = readBytesAsString(cutText, cutText.length) + "\n";
402
- this.copyright += decoded;
403
- SpessaSynthInfo(
404
- `%cDecoded Roland SC message! %c${decoded}`,
405
- consoleColors.recognized,
406
- consoleColors.value
407
- );
408
- }
472
+ // since this is a sysex message, do nothing
409
473
  break;
410
474
 
411
475
 
412
476
  default:
413
477
  // since this is a voice message
414
478
  // check for loop (CC 2/4)
479
+ trackHasVoiceMessages = true;
415
480
  if ((statusByte & 0xF0) === messageTypes.controllerChange)
416
481
  {
417
482
  switch (eventData[0])
@@ -450,6 +515,20 @@ class MIDI extends BasicMIDI
450
515
  }
451
516
  this.tracks.push(track);
452
517
  this.usedChannelsOnTrack.push(usedChannels);
518
+
519
+ // if the track has no voice messages, its "track name" event (if it has any)
520
+ // is some metadata. Add it to copyright
521
+ if (!trackHasVoiceMessages)
522
+ {
523
+ const trackName = track.find(e => e.messageStatusByte === messageTypes.trackName);
524
+ if (trackName)
525
+ {
526
+ trackName.messageData.currentIndex = 0;
527
+ const name = readBytesAsString(trackName.messageData, trackName.messageData.length);
528
+ copyrightComponents.push(name);
529
+ }
530
+ }
531
+
453
532
  SpessaSynthInfo(
454
533
  `%cParsed %c${this.tracks.length}%c / %c${this.tracksAmount}`,
455
534
  consoleColors.info,
@@ -569,6 +648,7 @@ class MIDI extends BasicMIDI
569
648
  if (name)
570
649
  {
571
650
  this.rawMidiName = name.messageData;
651
+ name.messageData.currentIndex = 0;
572
652
  this.midiName = readBytesAsString(name.messageData, name.messageData.length, undefined, false);
573
653
  }
574
654
  }
@@ -580,11 +660,23 @@ class MIDI extends BasicMIDI
580
660
  if (name)
581
661
  {
582
662
  this.rawMidiName = name.messageData;
663
+ name.messageData.currentIndex = 0;
583
664
  this.midiName = readBytesAsString(name.messageData, name.messageData.length, undefined, false);
584
665
  }
585
666
  }
586
667
  }
587
668
 
669
+ if (!copyrightDetected)
670
+ {
671
+ this.copyright = copyrightComponents
672
+ // trim and group newlines into one
673
+ .map(c => c.trim().replace(/(\r?\n)+/g, "\n"))
674
+ // remove empty strings
675
+ .filter(c => c.length > 0)
676
+ // join with newlines
677
+ .join("\n") || "";
678
+ }
679
+
588
680
  this.fileName = fileName;
589
681
  this.midiName = this.midiName.trim();
590
682
  this.midiNameUsesFileName = false;
@@ -612,6 +704,38 @@ class MIDI extends BasicMIDI
612
704
  consoleColors.recognized
613
705
  );
614
706
  }
707
+
708
+ // lyrics fix:
709
+ // sometimes, all lyrics events lack spaces at the start or end of the lyric
710
+ // then, and only then, add space at the end of each lyric
711
+ // space ASCII is 32
712
+ let lacksSpaces = true;
713
+ for (const lyric of this.lyrics)
714
+ {
715
+ if (lyric[0] === 32 || lyric[lyric.length - 1] === 32)
716
+ {
717
+ lacksSpaces = false;
718
+ break;
719
+ }
720
+ }
721
+
722
+ if (lacksSpaces)
723
+ {
724
+ this.lyrics = this.lyrics.map(lyric =>
725
+ {
726
+ // one exception: hyphens at the end. Don't add a space to them
727
+ if (lyric[lyric.length - 1] === 45)
728
+ {
729
+ return lyric;
730
+ }
731
+ const withSpaces = new Uint8Array(lyric.length + 1);
732
+ withSpaces.set(lyric, 0);
733
+ withSpaces[lyric.length] = 32;
734
+ return withSpaces;
735
+ });
736
+ }
737
+
738
+
615
739
  // reverse the tempo changes
616
740
  this.tempoChanges.reverse();
617
741