midi-audio-player 1.1.2 → 2.0.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.
@@ -1,109 +1,1437 @@
1
1
  import MidiPlayer from 'midi-player-js';
2
- import WebAudioFontPlayer from "./webaudiofontplayer";
3
- import DefaultPreset from "./presets/defaultpreset.json";
2
+ import WebAudioFontPlayer from 'webaudiofontplayer';
3
+ import AudioCompressor from './libraries/audiocompressor';
4
+ import indexedDbStorage from './libraries/indexeddbstorage';
4
5
 
6
+ const clamp = (num, min, max) => Math.min(Math.max(num, min), max);
5
7
 
6
8
 
7
9
  export default class MidiAudioPlayer extends MidiPlayer.Player {
8
10
 
9
- #audioCtx = null;
10
- #audioPlayer = null;
11
- #activeNotes = null;
11
+ static ENDPOINT = 'https://webaudiofonts.com/presets/';
12
+ static DEFAULT_PRESET = -1;
13
+ static REFERENCE_GAIN = 0.15;
14
+ static KARAOKE_CHANNEL = 0;
15
+
16
+ #catalog = null;
17
+ #audioCtx = null;
18
+ #compressor = null;
19
+ #vocalChannel = null;
20
+ #activeNotes = {};
21
+ #channelStates = {};
22
+ #instruments = {};
23
+ #players = {};
24
+ #channels = {};
25
+ #channelVolumes = {};
26
+ #presetMap = {};
27
+ #bufferHash = null;
28
+ #presetTimer = null;
29
+ #presetMapThread = null;
30
+ #lyrics = null;
31
+ #haveLyrics = false;
32
+ #title = '';
12
33
 
13
34
  #opts = {
14
- preset: DefaultPreset,
15
- volume: 0.012,
16
- onEndFile: null
35
+ endpoint: MidiAudioPlayer.ENDPOINT,
36
+ volume: 0.6,
37
+ reverb: 0.3,
38
+ onEndFile: null,
39
+ localCache: true,
40
+ presetRandom: false,
41
+ karaoke: false,
42
+ karaokeDelay: 0,
43
+ muteExpression: false,
44
+ maxCharPerLine: 48,
45
+ eqPreset: 'flat',
46
+ preferred: [],
47
+ presets: [],
17
48
  };
18
49
 
19
50
 
20
51
  constructor(opts = {}) {
21
- super(event => this.#handleMidiPipeline(event));
22
- this.#opts = { ...this.#opts, ...opts };
23
- this.#activeNotes = new Map();
52
+ super();
53
+ this.#opts = { ...this.#opts, ...opts };
54
+ this.#presetMapThread = this.#mapPresets();
24
55
  this.#audioCtx = new (window.AudioContext || window.webkitAudioContext)();
25
- this.#audioPlayer = new WebAudioFontPlayer(this.#audioCtx, this.#opts.preset);
26
- this.on('endOfFile', async () => {
27
- await new Promise(resolve => requestAnimationFrame(() => setTimeout(resolve, 1)));
28
- await this.#endOfFile();
29
- });
56
+ this.#compressor = new AudioCompressor(this.#audioCtx, this.#opts.volume, this.#opts.reverb);
57
+ this.#compressor.setEQPreset(this.#opts.eqPreset);
58
+ if(this.#opts.karaoke) this.#sendKaraokeFrame('intro');
30
59
  }
31
60
 
32
- async load(content) {
61
+ get catalog() { return this.getCatalog(); }
62
+ get channels() { return this.#players; }
63
+ get channelStates() { return this.#channelStates; }
64
+ get volume() { return this.#opts.volume; }
65
+ set volume(vol) { this.#opts.volume = clamp(vol, 0, 1); this.#compressor.masterVolume = this.#opts.volume; }
66
+ get volumes() { return this.#channelVolumes; }
67
+ get reverb() { return this.#compressor.reverb; }
68
+ set reverb(rev) { this.#compressor.reverb = rev; }
69
+ get muteExpression() { return this.#opts.muteExpression; }
70
+ set muteExpression(val) { this.#opts.muteExpression = Boolean(val); }
71
+ get eqFrequencies() { return this.#compressor.eqFrequencies; }
72
+ get eq() { return this.#compressor.getEQ(); }
73
+ getEQ() { return this.#compressor.getEQ(); }
74
+ setEQ(gains) { this.#compressor.setEQ(gains); }
75
+ setEQPreset(name) { this.#compressor.setEQPreset(name); }
76
+ setChannelVolume(channel, volume) { this.#channelVolumes[channel] = volume; this.#setupChange(); }
77
+
78
+
79
+ async #mapPresets() {
80
+ await Promise.all(this.#opts.presets.map(async p => {
81
+ const preset = await this.findPreset(p);
82
+ if(preset) this.#presetMap[preset.program] = preset;
83
+ }));
84
+ }
85
+
86
+
87
+ async findPreset(id) {
88
+ let preset = null;
89
+ const categories = await this.getCategories();
90
+ categories.some(c => {
91
+ c.instruments.some(i => {
92
+ preset = i.presets.find(p => p.id == id);
93
+ if(preset) {
94
+ preset.category = c.name;
95
+ preset.instrument = i.name;
96
+ preset.program = i.program;
97
+ return true;
98
+ }
99
+ });
100
+ if(preset) return true;
101
+ });
102
+ return preset;
103
+ }
104
+
105
+
106
+ async close() {
107
+ Object.keys(this.#players).forEach(id => this.#players[id].close());
108
+ await this.#audioCtx.close();
109
+ }
110
+
111
+
112
+ async getCatalog() {
113
+ if(this.#catalog) return this.#catalog;
114
+ const cachedata = this.#opts.localCache ? await sessionStorage.getItem('waf_catalog') : null;
115
+ if (cachedata) this.#catalog = JSON.parse(cachedata);
116
+ else {
117
+ this.#log(`Downloading catalog...`);
118
+ const response = await fetch(`${this.#opts.endpoint}catalog.json`);
119
+ if (!response.ok) throw new Error(`Impossible to download catalog: ${response.status}`);
120
+ this.#catalog = await response.json();
121
+ if(this.#opts.localCache) await sessionStorage.setItem('waf_catalog', JSON.stringify(this.#catalog));
122
+ }
123
+ const catalogDate = new Date(this.#catalog.updatedAt).getTime();
124
+ const catalogVersion = await indexedDbStorage.getItem(`waf_catalog_version`) || 1;
125
+ if(catalogVersion < catalogDate) {
126
+ await indexedDbStorage.clear();
127
+ indexedDbStorage.setItem(`waf_catalog_version`, catalogDate)
128
+ }
129
+ return this.#catalog;
130
+ }
131
+
132
+
133
+ async getCategories() {
134
+ return (await this.getCatalog()).categories;
135
+ }
136
+
137
+
138
+ async getProgramInstruments(program) {
139
+ const categories = await this.getCategories();
140
+ let instruments = [];
141
+ await Promise.all(categories.map(async category => category.instruments.filter(elm => elm.program == program).forEach(elm => {
142
+ elm.presets.forEach(p => {
143
+ p.instrument = category.name + ' / ' + elm.name;
144
+ instruments.push(p);
145
+ });
146
+ })));
147
+ return instruments;
148
+ }
149
+
150
+
151
+ async getPreset(id) {
152
+ try {
153
+ if(typeof id === 'object') return id;
154
+ const cacheid = `waf_preset_${id}`;
155
+ const cachedata = this.#opts.localCache ? await indexedDbStorage.getItem(cacheid) : null;
156
+ if (cachedata) return JSON.parse(cachedata);
157
+ this.#log(`Downloading preset ${id}...`);
158
+ const response = await fetch(`${MidiAudioPlayer.ENDPOINT}${id}.json`);
159
+ const preset = await response.json();
160
+ if(preset.zones === undefined) {
161
+ console.error(`Invalid preset: ${$id}`);
162
+ throw new Error(`Invalid preset: ${$id}`);
163
+ }
164
+ if(this.#opts.localCache) await indexedDbStorage.setItem(cacheid, JSON.stringify(preset), true);
165
+ return preset;
166
+ } catch(e) {
167
+ console.error(`Invalid preset: ${id}`);
168
+ throw new Error(`Invalid preset: ${id}`);
169
+ }
170
+ }
171
+
172
+
173
+ async loadPreset(presetId, channel) {
174
+ const presetInfo = await this.findPreset(presetId);
175
+ if(!presetInfo) throw new Error(`Invalid preset: ${presetId}`);
176
+ this.#presetMap[presetInfo.program] = presetInfo;
177
+ const preset = await this.getPreset(presetId);
178
+ this.#players[channel].preset = preset;
179
+ this.#setupChange();
180
+ }
181
+
182
+
183
+ async load(content, setup) {
184
+ if(typeof content === 'string') {
185
+ this.#log('Downloading song...');
186
+ const response = await fetch(content);
187
+ content = await response.arrayBuffer();
188
+ }
189
+ if(typeof setup === 'string') {
190
+ this.#log('Downloading setup...');
191
+ const response = await fetch(setup);
192
+ setup = await response.json();
193
+ }
194
+ this.#bufferHash = await this.hashBuffer(content);
195
+ await this.#presetMapThread;
33
196
  if(this.isPlaying()) this.stop();
34
197
  this.#clearActiveNotes();
35
- await this.loadArrayBuffer(content);
198
+ await Promise.all(Object.values(this.#players).map(async player => player.close()));
199
+ this.#players = {};
200
+ this.#instruments = {};
201
+ this.#activeNotes = {};
202
+ this.#title = "";
203
+ this.#log('Loading buffer...');
204
+ try {
205
+ await this.loadArrayBuffer(content);
206
+ } catch(e) {
207
+ await this.loadArrayBuffer(await this.#repairMidi(content));
208
+ }
209
+
210
+ this.#log('Loading instruments...');
211
+ this.#channels = await this.#getInstruments();
212
+ this.#channelStates = Object.keys(this.#channels).reduce((acc, key) => ({ ...acc, [key]: false }), {});
213
+ this.#channelVolumes = Object.keys(this.#channels).reduce((acc, key) => ({ ...acc, [key]: 1.0 }), {});
214
+ if(setup?.volumes !== undefined) {
215
+ await Promise.all(Object.keys(setup.volumes).map(async channel => {
216
+ if(this.#channelVolumes[channel] === undefined) return;
217
+ this.#channelVolumes[channel] = setup.volumes[channel];
218
+ }));
219
+ }
220
+
221
+ const setupPrograms = new Set();
222
+ const setupPresets = {};
223
+ if(setup?.presets !== undefined) {
224
+ await Promise.all(Object.keys(setup.presets).map(async channel => {
225
+ const presetInfo = await this.findPreset(setup.presets[channel]);
226
+ if(!presetInfo) return;
227
+ setupPresets[channel] = await this.getPreset(presetInfo.id);
228
+ setupPrograms.add(presetInfo.program);
229
+ }));
230
+ }
231
+
232
+ const uniqueInstruments = await this.#getUniqueInstruments();
233
+ if(!Object.values(this.#channels).length) this.#log("Error: no instrument found");
234
+ const presets = Promise.all([...uniqueInstruments].map(async program => {
235
+ if(setupPrograms.has(program)) return;
236
+ let preset = null;
237
+ if(this.#presetMap[program] !== undefined) preset = await this.getPreset(this.#presetMap[program].id);
238
+ else if(this.#opts.presetRandom) preset = await this.#getRandomPreset(program);
239
+ else preset = await this.#getAutoPreset(program);
240
+ this.#instruments[program] = preset;
241
+ }));
242
+
243
+ if(this.#opts.karaoke) {
244
+ this.#log('Generating karaoke frames...');
245
+ this.#lyrics = null;
246
+ await this.#generateKaraokeFrames();
247
+ if(this.#title) this.#sendKaraokeFrame('title', this.#title);
248
+ }
249
+
250
+ this.#log(`Trim midi events...`);
251
+ this.#trimMidiEvents();
252
+ queueMicrotask(() => this.triggerPlayerEvent('computed'));
253
+
254
+ await presets;
255
+ await Promise.all(Object.keys(this.#channels).map(async channel => {
256
+ if(this.#players[channel]) this.#players[channel].close();
257
+ if(setupPresets[channel] !== undefined) this.#players[channel] = await this.#createPlayer(setupPresets[channel]);
258
+ else this.#players[channel] = await this.#createPlayer(this.#instruments[this.#channels[channel]]);
259
+ }));
260
+
261
+ this.#log("Initializing instrument states...");
262
+ await this.#initInstrumentStates();
263
+ await this.triggerPlayerEvent('presetsLoaded', this.#instruments);
264
+ await this.#setupChange();
265
+ this.#log("Player ready");
266
+
36
267
  }
37
-
268
+
269
+
270
+ async getSongSetup() {
271
+ let setup = { hash: this.#bufferHash, presets: {}, volumes: {} };
272
+ Object.keys(this.#players).map(async channel => setup.presets[channel] = this.#players[channel].preset.id);
273
+ setup.volumes = this.#channelVolumes;
274
+ return setup;
275
+ }
276
+
277
+
278
+ async getTrainingPresets() {
279
+ return await Promise.all(Object.values(this.#presetMap).map(async preset => preset.id));
280
+ }
281
+
38
282
 
39
283
  async play(content = null) {
40
- if(content) await this.load(content);
41
- await this.#audioCtx.resume();
42
- await super.play();
284
+ if (this.#audioCtx.state === 'suspended') {
285
+ try { await this.#audioCtx.resume(); }
286
+ catch (e) { return false; }
287
+ }
288
+ if(content) await this.load(content);
289
+ await Promise.all(Object.keys(this.#players).map(async k => await this.#players[k]?.cancelQueue()));
290
+ this.#compressor.restoreReverb();
291
+ if(!this.isPlaying()) {
292
+ if (!this.startTime) this.startTime = new Date().getTime();
293
+ this.scheduledTime = Date.now();
294
+ this.schedulePlayLoop(this.sampleRate);
295
+ }
296
+ return true;
43
297
  }
44
-
298
+
45
299
 
46
300
  async pause() {
47
301
  await super.pause();
302
+ this.#compressor.killReverbTail();
48
303
  await this.#clearActiveNotes();
49
- await this.#audioPlayer.cancelQueue();
304
+ await Promise.all(Object.keys(this.#players).map(async k => await this.#players[k]?.cancelQueue()));
50
305
  }
51
306
 
52
-
53
- async stop() {
307
+
308
+ async stop(skipKill = false) {
54
309
  await super.stop();
310
+ this.setTimeoutId = false;
311
+ if(!skipKill) {
312
+ this.#compressor.killReverbTail();
313
+ await Promise.all(Object.keys(this.#players).map(async k => await this.#players[k]?.cancelQueue()));
314
+ }
55
315
  await this.#clearActiveNotes();
56
- await this.#audioPlayer.cancelQueue();
316
+ if(this.#opts.karaoke) this.#sendKaraokeFrame('intro');
317
+ return this;
57
318
  }
58
319
 
59
320
 
60
- async #endOfFile() {
61
- if(typeof this.#opts.onEndFile == 'function') await this.#opts.onEndFile();
62
- }
321
+ getRealTimeVolume() {
322
+ const analyser = this.#compressor.analyser;
323
+ const dataArray = new Uint8Array(analyser.frequencyBinCount);
324
+ analyser.getByteFrequencyData(dataArray);
325
+ let values = 0;
326
+ for (let i = 0; i < dataArray.length; i++) values += dataArray[i];
327
+ return values / (dataArray.length * 100);
328
+ }
63
329
 
64
330
 
65
- async #handleMidiPipeline(event) {
66
- if (event.name !== 'Note on' && event.name !== 'Note off') return;
67
- if (!this.isPlaying()) return;
68
- if (event.noteNumber === undefined) return;
331
+ getSongTimeRemaining() {
332
+ return this.ticksToSeconds(this.getCurrentTick(), this.totalTicks);
333
+ }
334
+
335
+
336
+ async skipToSeconds(seconds) {
337
+ const songTime = this.getSongTime();
338
+ if (seconds < 0 || seconds > songTime) throw seconds + " seconds not within song time of " + songTime;
339
+ await this.skipToTick(this.secondsToTicks(seconds));
340
+ return this;
341
+ }
342
+
343
+
344
+ async generateWaveformSVG(samples = 1000) {
345
+ if (!this.totalTicks || !this.events) return '';
346
+ const waveform = new Array(samples).fill(0);
347
+ const tickInterval = this.totalTicks / samples;
348
+ const allEvents = this.events
349
+ .flatMap((track, trackIdx) =>
350
+ track.map(event => ({
351
+ ...event,
352
+ computedChannel: event.channel !== undefined ? event.channel : trackIdx
353
+ }))
354
+ )
355
+ .filter(event =>
356
+ event.name === 'Controller Change' ||
357
+ event.name === 'Program Change' ||
358
+ (event.name === 'Note on' && event.velocity > 0)
359
+ )
360
+ .sort((a, b) => a.tick - b.tick);
361
+ const channelsVolume = new Map();
362
+ const channelsExpression = new Map();
363
+ allEvents.forEach(event => {
364
+ const idx = Math.floor(event.tick / tickInterval);
365
+ if (idx >= samples) return;
366
+ const chan = event.computedChannel;
367
+ if (!channelsVolume.has(chan)) channelsVolume.set(chan, 100);
368
+ if (!channelsExpression.has(chan)) channelsExpression.set(chan, 127);
369
+ if (event.name === 'Controller Change') {
370
+ if (event.number === 7) channelsVolume.set(chan, event.value);
371
+ else if (event.number === 11) channelsExpression.set(chan, event.value);
372
+ }
373
+ else if (event.name === 'Note on') {
374
+ const volFactor = channelsVolume.get(chan) / 127;
375
+ const expFactor = channelsExpression.get(chan) / 127;
376
+ const modulatedVelocity = event.velocity * volFactor * expFactor;
377
+ waveform[idx] += modulatedVelocity;
378
+ }
379
+ });
380
+ const maxAmp = waveform.reduce((max, val) => {
381
+ if (isNaN(val)) return max;
382
+ return val > max ? val : max;
383
+ }, 0);
384
+ const normalized = maxAmp > 0 ? waveform.map(v => isNaN(v) ? 0 : v / maxAmp) : waveform.fill(0);
385
+ const width = samples;
386
+ const height = width / 5;
387
+ const points = normalized.map((val, i) => {
388
+ const x = i;
389
+ const y = Math.max(0, Math.min(height, height - (val * height)));
390
+ return `${x},${y.toFixed(2)}`;
391
+ });
392
+ const d = `M 0,${height} L ${points.join(' L ')} L ${width},${height}`;
393
+ return `<svg class="midiaudioplayer-waveform" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none"><path d="${d}" fill="none" stroke-linecap="round" stroke-linejoin="round" /></svg>`;
394
+ }
395
+
396
+ // ----------------------------------------------------------------------------------------------------------------------
397
+ // ----------------------------------------------------------------------------------------------------------------------
398
+ // ----------------------------------------------------------------------------------------------------------------------
399
+
69
400
 
70
- const now = this.#audioCtx.currentTime;
401
+ async hashBuffer(arrayBuffer, algorithm = 'SHA-256') {
402
+ const hashBuffer = await crypto.subtle.digest(algorithm, arrayBuffer);
403
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
404
+ return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
405
+ }
406
+
407
+
408
+ async #setupChange() {
409
+ if(this.#presetTimer) clearTimeout(this.#presetTimer);
410
+ this.#presetTimer = setTimeout(async () => {
411
+ const setup = await this.getSongSetup();
412
+ queueMicrotask(() => this.triggerPlayerEvent('setupChange', setup));
413
+ }, 1000);
414
+ }
415
+
416
+
417
+ async triggerPlayerEvent(playerEvent, data) {
418
+ if(playerEvent == 'fileLoaded') return;
419
+ else if(playerEvent == 'computed') {
420
+ this.#vocalChannel = await this.#detectKaraokeVocalChannel();
421
+ super.triggerPlayerEvent(playerEvent, {
422
+ title: this.#title,
423
+ karaoke: this.#haveLyrics,
424
+ vocalChannel: this.#vocalChannel,
425
+ tempo: this.tempo,
426
+ division: this.division,
427
+ duration: this.getSongTime(),
428
+ sampleRate: this.sampleRate,
429
+ totalTicks: this.totalTicks,
430
+ totalEvents: this.totalEvents,
431
+ channels: await this.#channels,
432
+ });
433
+ } else if(playerEvent == 'endOfFile' && this.#opts.karaoke) {
434
+ queueMicrotask(() => super.triggerPlayerEvent(playerEvent, data));
435
+ } else super.triggerPlayerEvent(playerEvent, data);
436
+ }
437
+
438
+
439
+ async playLoop(dryRun) {
440
+ if (this.inLoop) return;
441
+ if (!dryRun && this.endOfFile() && this.tick > 0) {
442
+ await this.stop(true);
443
+ this.tick = 0;
444
+ this.triggerPlayerEvent('endOfFile');
445
+ return;
446
+ }
447
+ this.inLoop = true;
448
+ this.tick = this.getCurrentTick();
449
+ const tracksLen = this.tracks.length;
450
+ for (let i = 0; i < tracksLen; i++) {
451
+ const result = this.tracks[i].handleEvent(this.tick, dryRun);
452
+ if (!result) continue;
453
+ const isArray = result.constructor === Array;
454
+ const eventsLen = isArray ? result.length : 1;
455
+ for (let j = 0; j < eventsLen; j++) {
456
+ const event = isArray ? result[j] : result;
457
+ const { name, data, value } = event;
458
+ if (name === 'Set Tempo') this.setTempo(data);
459
+ if (dryRun) {
460
+ if (name === 'Program Change' && !this.instruments.includes(value)) this.instruments.push(value);
461
+ } else {
462
+ this.emitEvent(event);
463
+ }
464
+ }
465
+ }
466
+ if (!dryRun && this.isPlaying()) this.triggerPlayerEvent('playing', { tick: this.tick });
467
+ this.inLoop = false;
468
+ }
71
469
 
470
+
471
+ schedulePlayLoop(delay) {
472
+ this.setTimeoutId = setTimeout(() => {
473
+ if (this.setTimeoutId === false) return;
474
+ this.playLoop();
475
+ const currentAudioTime = this.#audioCtx.currentTime;
476
+ if (!this._lastAudioTime) this._lastAudioTime = currentAudioTime;
477
+ const elapsed = currentAudioTime - this._lastAudioTime;
478
+ this._lastAudioTime = currentAudioTime;
479
+ const sampleRateSec = this.sampleRate / 1000;
480
+ const drift = elapsed - sampleRateSec;
481
+ const nextDelay = Math.max(0, this.sampleRate - (drift * 1000));
482
+ this.schedulePlayLoop(nextDelay);
483
+ }, delay);
484
+ }
485
+
486
+
487
+ emitEvent(event) {
488
+ this.#handleMidiPipeline(event);
489
+ }
490
+
491
+
492
+ ticksToSeconds(startTick, endTick) {
493
+ if (endTick === undefined) {
494
+ endTick = startTick;
495
+ startTick = 0;
496
+ }
497
+ if (startTick >= endTick) return 0;
498
+ let seconds = 0;
499
+ const len = this.tempoMap.length;
500
+ const timeFactor = 60 / this.division;
501
+ let low = 0;
502
+ let high = len - 1;
503
+ let startIndex = 0;
504
+ while (low <= high) {
505
+ const mid = (low + high) >> 1;
506
+ if (this.tempoMap[mid].tick <= startTick) {
507
+ startIndex = mid;
508
+ low = mid + 1;
509
+ } else {
510
+ high = mid - 1;
511
+ }
512
+ }
513
+ let currentTick = startTick;
514
+ for (let i = startIndex; i < len; i++) {
515
+ const entry = this.tempoMap[i];
516
+ const nextTick = (i + 1 < len) ? this.tempoMap[i + 1].tick : endTick;
517
+ if (nextTick <= startTick) continue;
518
+ const segStart = Math.max(entry.tick, startTick);
519
+ const segEnd = Math.min(nextTick, endTick);
520
+ if (segStart >= endTick) break;
521
+ seconds += ((segEnd - segStart) / entry.tempo) * timeFactor;
522
+ currentTick = segEnd;
523
+ }
524
+ if (currentTick < endTick) {
525
+ const lastEntry = this.tempoMap[len - 1];
526
+ seconds += ((endTick - currentTick) / lastEntry.tempo) * timeFactor;
527
+ }
528
+ return seconds;
529
+ }
530
+
531
+
532
+ secondsToTicks(seconds) {
533
+ let remainingSeconds = seconds;
534
+ const len = this.tempoMap.length;
535
+ const factor = 60 / this.division;
536
+ for (let i = 0; i < len; i++) {
537
+ const entry = this.tempoMap[i];
538
+ const nextTick = (i + 1 < len) ? this.tempoMap[i + 1].tick : Infinity;
539
+ const segmentTicks = nextTick - entry.tick;
540
+ const segmentSeconds = (segmentTicks / entry.tempo) * factor;
541
+ if (remainingSeconds <= segmentSeconds) {
542
+ return entry.tick + Math.round((remainingSeconds * entry.tempo) / factor);
543
+ }
544
+ remainingSeconds -= segmentSeconds;
545
+ }
546
+ return this.totalTicks;
547
+ }
548
+
549
+
550
+ getTickBeforeSeconds(targetTick, seconds) {
551
+ if (targetTick <= 0) return 0;
552
+ const targetTime = this.ticksToSeconds(0, targetTick);
553
+ const desiredTime = Math.max(0, targetTime - seconds);
554
+ return this.secondsToTicks(desiredTime);
555
+ }
556
+
557
+
558
+ async skipToTick(tick) {
559
+ const safeTick = Math.max(0, Math.min(tick, this.totalTicks || 0));
560
+ const wasPlaying = this.isPlaying();
561
+ this.#clearActiveNotes();
562
+ Object.keys(this.channels).forEach(k => this.channels[k]?.cancelQueue?.());
563
+ if (wasPlaying) super.pause();
564
+ this.startTick = safeTick;
565
+ this.tick = safeTick;
566
+ if (this.tempoMap && this.tempoMap.length > 0) {
567
+ for (let i = this.tempoMap.length - 1; i >= 0; i--) {
568
+ if (this.tempoMap[i].tick <= safeTick) {
569
+ this.setTempo(this.tempoMap[i].tempo);
570
+ break;
571
+ }
572
+ }
573
+ }
574
+ try {
575
+ const controllerChange = [];
576
+ const programChange = [];
577
+ const pitchBend = [];
578
+ const karaokeEvent = [];
579
+
580
+ this.#collectStateAtTick(safeTick).forEach(event => {
581
+ const channel = event.channel;
582
+ if ((channel === undefined || !this.channels[channel]) && event.name !== 'Karaoke Event') return;
583
+ switch(event.name) {
584
+ case 'Controller Change': controllerChange[event.channel] = event; break;
585
+ case 'Program Change': programChange[event.channel] = event; break;
586
+ case 'Pitch Bend': pitchBend[event.channel] = event; break;
587
+ case 'Karaoke Event': karaokeEvent[event.channel] = event; break;
588
+ }
589
+ });
590
+ controllerChange.forEach(evt => this.emitEvent(evt));
591
+ programChange.forEach(evt => this.emitEvent(evt));
592
+ pitchBend.forEach(evt => this.emitEvent(evt));
593
+ karaokeEvent.forEach(evt => this.triggerPlayerEvent('karaoke', { type: evt.type, tick: evt.tick, html: evt.text}));
594
+ } catch (e) {
595
+ console.warn("Chase MIDI Error:", e);
596
+ this.#log("Chase MIDI Error:", e);
597
+ }
598
+ if (this.tracks && this.tracks.length > 0) {
599
+ this.tracks.forEach((track, index) => {
600
+ const trackEvents = this.events[index];
601
+ if (trackEvents && trackEvents.length > 0) {
602
+ let low = 0;
603
+ let high = trackEvents.length - 1;
604
+ let pointer = trackEvents.length;
605
+ while (low <= high) {
606
+ const mid = (low + high) >> 1;
607
+ if (trackEvents[mid].tick >= safeTick) {
608
+ pointer = mid;
609
+ high = mid - 1;
610
+ } else {
611
+ low = mid + 1;
612
+ }
613
+ }
614
+ track.eventIndex = pointer;
615
+ } else if (typeof track.setEventIndexByTick === 'function') {
616
+ track.setEventIndexByTick(safeTick);
617
+ }
618
+ });
619
+ }
620
+ if (wasPlaying) this.play();
621
+ else this.triggerPlayerEvent('playing', { tick: safeTick });
622
+ return this;
623
+ }
624
+
625
+
626
+ #collectStateAtTick(tick) {
627
+ const dominated = {};
628
+ if (!this.events) return [];
629
+ for (let t = 0; t < this.events.length; t++) {
630
+ const trackEvents = this.events[t];
631
+ if (!trackEvents || trackEvents.length === 0) continue;
632
+ let low = 0;
633
+ let high = trackEvents.length - 1;
634
+ let endIdx = trackEvents.length;
635
+ while (low <= high) {
636
+ const mid = (low + high) >> 1;
637
+ if (trackEvents[mid].tick >= tick) {
638
+ endIdx = mid;
639
+ high = mid - 1;
640
+ } else {
641
+ low = mid + 1;
642
+ }
643
+ }
644
+ for (let i = 0; i < endIdx; i++) {
645
+ const event = trackEvents[i];
646
+ let key;
647
+ if (event.name === 'Program Change') {
648
+ key = 'pc:' + event.channel;
649
+ }else if (event.name === 'Controller Change') {
650
+ key = 'cc:' + event.channel + ':' + event.number;
651
+ } else if (event.name === 'Pitch Bend') {
652
+ key = 'pb:' + event.channel;
653
+ } else if (event.name === 'Karaoke Event') {
654
+ key = 'ke:' + event.channel;
655
+ }
656
+ if (key) {
657
+ dominated[key] = event;
658
+ }
659
+ }
660
+ }
661
+ return Object.keys(dominated).map(key => dominated[key]);
662
+ }
663
+
664
+
665
+ async #initInstrumentStates() {
666
+ if (this.events) {
667
+ this.#collectStateAtTick(1).forEach(event => {
668
+ const channel = event.channel;
669
+ if (!this.#players[channel]) return;
670
+ switch (event.name) {
671
+ case 'Controller Change':
672
+ this.#players[channel].setController(event.number, event.value);
673
+ break;
674
+ case 'Pitch Bend':
675
+ this.#players[channel].setPitchBend?.(event.value);
676
+ break;
677
+ case 'Program Change':
678
+ if (
679
+ // (this.#opts.presetAuto || this.#opts.presetRandom) &&
680
+ event.value >= 0 && event.value <= 127 &&
681
+ this.#instruments[event.value + 1] !== undefined &&
682
+ event.channel != 10) {
683
+ if (this.#players[channel].preset?.program !== (event.value + 1)) {
684
+ this.#players[channel].preset = this.#instruments[event.value + 1];
685
+ }
686
+ }
687
+ break;
688
+ }
689
+ });
690
+ }
691
+ }
692
+
693
+
694
+ async #getInstruments() {
695
+ const instrumentMap = {};
696
+ const channelUsed = new Set();
697
+ this.events.forEach(track => {
698
+ track.forEach(event => {
699
+ if (event.name === 'Program Change' && event.value >= 0 && event.value <= 127) {
700
+ if(instrumentMap[event.channel]) return;
701
+ else if(event.channel == 10) instrumentMap[event.channel] = -1;
702
+ else instrumentMap[event.channel] = event.value + 1;
703
+ } else if (event.name === 'Note on' && event.channel == 10) {
704
+ instrumentMap[event.channel] = -1;
705
+ channelUsed.add(10);
706
+ } else if(event.name === 'Note on') {
707
+ channelUsed.add(event.channel);
708
+ }
709
+ });
710
+ });
711
+ Object.keys(instrumentMap).forEach(channel => {
712
+ if(!channelUsed.has(Number(channel))) delete instrumentMap[channel];
713
+ });
714
+ return instrumentMap;
715
+ }
716
+
717
+
718
+ async #getUniqueInstruments() {
719
+ const instrumentMap = new Set();
720
+ this.events.forEach(track => {
721
+ track.forEach(event => {
722
+ if (event.name === 'Program Change' && event.value >= 0 && event.value <= 127) {
723
+ instrumentMap.add(event.channel == 10 ? -1 : (event.value + 1));
724
+ } else if (event.name === 'Note on' && event.channel == 10) instrumentMap.add(-1);
725
+ });
726
+ });
727
+ return instrumentMap;
728
+ }
729
+
730
+
731
+ async #getRandomPreset(program) {
732
+ const instruments = await this.getProgramInstruments(program);
733
+ if(!instruments.length) return null;
734
+ let preset = null;
735
+ this.#opts.preferred.some(bank => {
736
+ const regex = new RegExp(`_${bank}$`, 'i');
737
+ const group = instruments.filter(i => regex.test(i.id));
738
+ if(group.length) {
739
+ preset = group[Math.floor(Math.random() * group.length)];
740
+ return true;
741
+ }
742
+ });
743
+ if(!preset) preset = instruments[Math.floor(Math.random() * instruments.length)];
744
+ this.#presetMap[program] = preset;
745
+ return await this.getPreset(preset.id);
746
+ }
747
+
748
+
749
+ async #getAutoPreset(program) {
750
+ const instruments = await this.getProgramInstruments(program);
751
+ if(!instruments.length) return null;
752
+ let preset = null;
753
+ this.#opts.preferred.some(bank => {
754
+ const regex = new RegExp(`_${bank}$`, 'i');
755
+ preset = instruments.find(i => regex.test(i.id));
756
+ if(preset) return true;
757
+ });
758
+ if(!preset) preset = instruments[0];
759
+ this.#presetMap[program] = preset;
760
+ return await this.getPreset(preset.id);
761
+ }
762
+
763
+
764
+ async #createPlayer(preset) {
765
+ return new WebAudioFontPlayer(preset, this.#audioCtx, this.#compressor);
766
+ }
767
+
768
+
769
+ async #handleMidiPipeline(event) {
770
+ if(!this.isPlaying()) return;
72
771
  switch (event.name) {
73
772
  case 'Note on':
773
+ if (event.tick < (this.tick - 100)) return;
774
+ if (event.noteNumber === undefined) return;
775
+ if (event.channel == this.#vocalChannel && this.#opts.muteExpression) return;
74
776
  if (event.velocity > 0 && event.velocity <= 127) {
75
- this.#stopNotePipe(event.noteNumber);
76
- const vol = (event.velocity / 127) * this.#opts.volume;
77
- const envelope = this.#audioPlayer.queueWaveTable(0, event.noteNumber, 2, vol);
78
- this.#activeNotes.set(event.noteNumber, envelope);
777
+ this.#stopNote(event.channel, event.noteNumber);
778
+ if(this.#channelVolumes[event.channel] == 0) return;
779
+ const noteVelocityRatio = event.velocity / 127;
780
+ const finalVol = MidiAudioPlayer.REFERENCE_GAIN * Math.pow(noteVelocityRatio, 2) * this.#channelVolumes[event.channel];
781
+ const envelope = this.#players[event.channel]?.queueWaveTable(0, event.noteNumber, 2, finalVol);
782
+ if (envelope) this.#addNote(event.channel, event.noteNumber, envelope)
79
783
  } else {
80
- this.#stopNotePipe(event.noteNumber);
784
+ this.#stopNote(event.channel, event.noteNumber);
81
785
  }
82
786
  break;
83
-
84
787
  case 'Note off':
85
- this.#stopNotePipe(event.noteNumber);
788
+ if (event.noteNumber === undefined) return;
789
+ this.#stopNote(event.channel, event.noteNumber);
790
+ break;
791
+ case 'Controller Change':
792
+ this.#players[event.channel]?.setController(event.number, event.value);
793
+ break;
794
+ case 'Pitch Bend':
795
+ this.#players[event.channel]?.setPitchBend?.(event.value);
796
+ break;
797
+ case 'Program Change':
798
+ return;
799
+ if(!this.#players[event.channel]) return;
800
+ if(event.channel == 10 || event.value > 127 || event.value < 0) break;
801
+ if(this.#players[event.channel] !== undefined && this.#players[event.channel].preset.program != (event.value + 1))
802
+ this.#players[event.channel].preset = this.#instruments[event.value + 1];
803
+ break;
804
+ case 'Karaoke Event':
805
+ if (event.tick < (this.tick - this.secondsToTicks(10))) return;
806
+ this.triggerPlayerEvent('karaoke', { type: event.type, tick: event.tick, html: event.text});
86
807
  break;
87
808
  }
88
809
  }
89
810
 
90
811
 
91
- #stopNotePipe(noteNumber) {
92
- const envelope = this.#activeNotes.get(noteNumber);
812
+ #addNote(channel, note, envelope) {
813
+ if (!this.#activeNotes[channel]) this.#activeNotes[channel] = new Map();
814
+ this.#activeNotes[channel].set(note, envelope);
815
+ this.#updateChannelStates();
816
+ const realDurationMs = (envelope.duration || 0) * 1000;
817
+ envelope.cleanupTimer = setTimeout(() => {
818
+ if (this.#activeNotes[channel]?.get(note) === envelope) {
819
+ this.#activeNotes[channel].delete(note);
820
+ this.#updateChannelStates();
821
+ }
822
+ }, realDurationMs + 50);
823
+ }
824
+
825
+
826
+ #stopNote(channel, noteNumber) {
827
+ const player = this.#players[channel];
828
+ const envelope = this.#activeNotes[channel]?.get(noteNumber);
93
829
  if (envelope) {
94
- envelope.cancel();
95
- this.#activeNotes.delete(noteNumber);
830
+ if (envelope.cleanupTimer) clearTimeout(envelope.cleanupTimer);
831
+ const removeNoteFromRegistry = () => {
832
+ this.#activeNotes[channel]?.delete(noteNumber);
833
+ this.#updateChannelStates();
834
+ };
835
+ if (player && player.isSustainActive()) {
836
+ player.registerSustainNote(() => envelope.cancel(false));
837
+ } else {
838
+ envelope.cancel(false);
839
+ }
840
+ removeNoteFromRegistry();
96
841
  }
97
842
  }
98
843
 
99
844
 
100
845
  #clearActiveNotes() {
101
- if (this.#activeNotes) {
102
- this.#activeNotes.forEach((envelope, note) => { if (envelope && envelope.cancel) envelope.cancel(); });
103
- this.#activeNotes.clear();
846
+ Object.keys(this.#activeNotes).forEach(channel => {
847
+ this.#activeNotes[channel].forEach((envelope, note) => {
848
+ if (envelope) {
849
+ if (envelope.cleanupTimer) clearTimeout(envelope.cleanupTimer);
850
+ if (envelope.cancel) envelope.cancel(true);
851
+ }
852
+ this.#activeNotes[channel]?.delete(note);
853
+ });
854
+ });
855
+ this.#updateChannelStates();
856
+ }
857
+
858
+
859
+ async #updateChannelStates() {
860
+ let hasChanged = false;
861
+ const nextStates = {};
862
+ Object.keys(this.#players).forEach(channel => {
863
+ const isActive = Boolean(this.#activeNotes[channel]?.size && this.#activeNotes[channel].size > 0);
864
+ nextStates[channel] = isActive;
865
+ if (this.#channelStates[channel] !== isActive) hasChanged = true;
866
+ });
867
+ if (hasChanged) {
868
+ this.#channelStates = nextStates;
869
+ this.triggerPlayerEvent('channelState', this.#channelStates);
104
870
  }
105
871
  }
106
872
 
107
- }
108
873
 
109
- // export { MidiAudioPlayer };
874
+ async #repairMidi(buffer) {
875
+ const src = new Uint8Array(buffer);
876
+ const view = new DataView(buffer);
877
+ const magic = String.fromCharCode(...src.slice(0, 4));
878
+ if (magic !== 'MThd') throw new Error('Invalid MIDI file (MThd missing)');
879
+ const headerLen = view.getUint32(4);
880
+ const format = view.getUint16(8);
881
+ const ntrks = view.getUint16(10);
882
+ const division = view.getUint16(12);
883
+ const EOT = [0xFF, 0x2F, 0x00];
884
+ const chunks = [];
885
+ let pos = 8 + headerLen;
886
+ while (pos < src.length) {
887
+ if (pos + 8 > src.length) {
888
+ break;
889
+ }
890
+ const tag = String.fromCharCode(...src.slice(pos, pos + 4));
891
+ const declaredLen = view.getUint32(pos + 4);
892
+ const dataStart = pos + 8;
893
+ const dataEnd = dataStart + declaredLen;
894
+ if (tag !== 'MTrk') {
895
+ const end = Math.min(dataEnd, src.length);
896
+ chunks.push({ tag, data: src.slice(pos, end), repaired: false });
897
+ pos = dataEnd;
898
+ continue;
899
+ }
900
+ const trackNum = chunks.filter(c => c.tag === 'MTrk').length + 1;
901
+ const available = Math.min(declaredLen, src.length - dataStart);
902
+ const trackData = src.slice(dataStart, dataStart + available);
903
+ const last3 = trackData.slice(-3);
904
+ const hasEOT = last3[0] === 0xFF && last3[1] === 0x2F && last3[2] === 0x00;
905
+ if (hasEOT && available === declaredLen) {
906
+ chunks.push({ tag, data: trackData, repaired: false });
907
+ } else {
908
+ let repairedData;
909
+ if (available < declaredLen) {
910
+ const missing = declaredLen - available;
911
+ const last2 = trackData.slice(-2);
912
+ if (last2[0] === 0xFF && last2[1] === 0x2F) {
913
+ repairedData = new Uint8Array(trackData.length + 1);
914
+ repairedData.set(trackData);
915
+ repairedData[trackData.length] = 0x00;
916
+ } else {
917
+ repairedData = new Uint8Array(trackData.length + 3);
918
+ repairedData.set(trackData);
919
+ repairedData.set(EOT, trackData.length);
920
+ }
921
+ } else {
922
+ repairedData = new Uint8Array(trackData.length + 3);
923
+ repairedData.set(trackData);
924
+ repairedData.set(EOT, trackData.length);
925
+ }
926
+ chunks.push({ tag, data: repairedData, repaired: true });
927
+ }
928
+ pos = dataEnd;
929
+ }
930
+ const fixedNtrks = chunks.filter(c => c.tag === 'MTrk').length;
931
+ const totalSize = 14 + chunks.reduce((acc, c) => acc + 8 + c.data.length, 0);
932
+ const out = new Uint8Array(totalSize);
933
+ const outView = new DataView(out.buffer);
934
+ out.set([0x4D, 0x54, 0x68, 0x64], 0);
935
+ outView.setUint32(4, 6);
936
+ outView.setUint16(8, format);
937
+ outView.setUint16(10, fixedNtrks);
938
+ outView.setUint16(12, division);
939
+ let outPos = 14;
940
+ for (const chunk of chunks) {
941
+ const tagBytes = chunk.tag.split('').map(c => c.charCodeAt(0));
942
+ out.set(tagBytes, outPos);
943
+ outView.setUint32(outPos + 4, chunk.data.length);
944
+ out.set(chunk.data, outPos + 8);
945
+ outPos += 8 + chunk.data.length;
946
+ }
947
+ const repairedCount = chunks.filter(c => c.repaired).length;
948
+ return out.buffer;
949
+ }
950
+
951
+
952
+ #trimMidiEvents() {
953
+ if (!this.events || this.events.length === 0) return;
954
+ let firstNoteTick = Infinity;
955
+ let lastNoteTick = 0;
956
+ this.events.forEach(track => {
957
+ track.forEach(event => {
958
+ if (event.name === 'Note on' || event.name === 'Note off') {
959
+ if (event.tick < firstNoteTick) firstNoteTick = event.tick;
960
+ if (event.tick > lastNoteTick) lastNoteTick = event.tick;
961
+ }
962
+ });
963
+ });
964
+ if (firstNoteTick === Infinity) return;
965
+ const allSetupEventsBeforeFirstNote = [];
966
+ this.events.forEach((track, trackIdx) => {
967
+ track.forEach(event => {
968
+ const isSetupEvent = event.name === 'Program Change' ||
969
+ event.name === 'Controller Change' ||
970
+ event.name === 'Pitch Bend' ||
971
+ event.name === 'Set Tempo';
972
+ if (event.tick < firstNoteTick && isSetupEvent) {
973
+ allSetupEventsBeforeFirstNote.push({ event, trackIdx });
974
+ }
975
+ });
976
+ });
977
+ const uniqueSetupByTrack = Object.fromEntries(this.events.map((_, idx) => [idx, []]));
978
+ const globalUniqueKeys = new Set();
979
+ for (let i = allSetupEventsBeforeFirstNote.length - 1; i >= 0; i--) {
980
+ const { event, trackIdx } = allSetupEventsBeforeFirstNote[i];
981
+ const channel = event.channel !== undefined ? event.channel : `track-${trackIdx}`;
982
+ let key = null;
983
+ if (event.name === 'Program Change') {
984
+ key = `pc:${channel}`;
985
+ } else if (event.name === 'Controller Change') {
986
+ key = `cc:${channel}:${event.number}`;
987
+ } else if (event.name === 'Pitch Bend') {
988
+ key = `pb:${channel}`;
989
+ } else if (event.name === 'Set Tempo') {
990
+ key = 'tempo';
991
+ }
992
+ if (key) {
993
+ if (!globalUniqueKeys.has(key)) {
994
+ globalUniqueKeys.add(key);
995
+ const clonedEvent = { ...event, tick: 0 };
996
+ uniqueSetupByTrack[trackIdx].push(clonedEvent);
997
+ }
998
+ }
999
+ }
1000
+ const trimmedEvents = this.events.map((track, trackIdx) => {
1001
+ const newTrack = [];
1002
+ track.forEach(event => {
1003
+ const isSetupEvent = event.name === 'Program Change' ||
1004
+ event.name === 'Controller Change' ||
1005
+ event.name === 'Pitch Bend' ||
1006
+ event.name === 'Set Tempo';
1007
+ const isTextOrKaraoke = event.name === 'Text Event' ||
1008
+ event.name === 'Lyric Event' ||
1009
+ event.name === 'Track Name' ||
1010
+ event.name === 'Karaoke Event';
1011
+
1012
+ if (event.tick < firstNoteTick) {
1013
+ if (!isSetupEvent && (isTextOrKaraoke || trackIdx === 0)) {
1014
+ event.tick = 0;
1015
+ newTrack.push(event);
1016
+ }
1017
+ } else {
1018
+ event.tick = event.tick - firstNoteTick;
1019
+ const maxAllowedTick = lastNoteTick - firstNoteTick;
1020
+ if (event.tick > maxAllowedTick) {
1021
+ event.tick = maxAllowedTick;
1022
+ }
1023
+ newTrack.push(event);
1024
+ }
1025
+ });
1026
+ const filteredTrackSetup = uniqueSetupByTrack[trackIdx] || [];
1027
+ return [...filteredTrackSetup, ...newTrack].sort((a, b) => a.tick - b.tick);
1028
+ });
1029
+ this.events = trimmedEvents;
1030
+ this.totalTicks = lastNoteTick - firstNoteTick;
1031
+ if (typeof this.computeTempoMap === 'function') this.computeTempoMap();
1032
+ }
1033
+
1034
+
1035
+ async #extractLyrics() {
1036
+ if (this.#lyrics) return this.#lyrics;
1037
+ const structure = { language: "", title: "", paragraphs: [] };
1038
+ let bestTrack = null;
1039
+ let maxTextEventsCount = 0;
1040
+ this.events.forEach(track => {
1041
+ const textEventsInTrack = track.filter(e =>
1042
+ e.name === 'Text Event' ||
1043
+ e.name === 'Lyric Event' ||
1044
+ e.name === 'Cue Point' ||
1045
+ e.name === 'Marker' ||
1046
+ e.name === 'Track Name'
1047
+ );
1048
+ const realLyricsCount = textEventsInTrack.filter(e => {
1049
+ const textStr = e.string || e.text || "";
1050
+ return textStr && !textStr.startsWith('@');
1051
+ }).length;
1052
+ if (realLyricsCount > maxTextEventsCount) {
1053
+ maxTextEventsCount = realLyricsCount;
1054
+ bestTrack = textEventsInTrack;
1055
+ }
1056
+ });
1057
+
1058
+ if (!bestTrack || bestTrack.length === 0) return structure;
1059
+ const allTextEvents = bestTrack.sort((a, b) => a.tick - b.tick);
1060
+ let paragraphs = [];
1061
+ let currentParaLines = [];
1062
+ let currentLineBlocks = [];
1063
+ let lastBlockTick = 0;
1064
+ allTextEvents.forEach(event => {
1065
+ let text = this.#decodeKaraokeString(event.string || "");
1066
+ if (!text) return;
1067
+ if (/^Track-/i.test(text.trim()) ||
1068
+ /^Piste/i.test(text.trim()) ||
1069
+ text.trim() === "" ||
1070
+ (event.tick === 0 && text.length > 20)) {
1071
+ return;
1072
+ }
1073
+ if (text.startsWith('@L')) {
1074
+ structure.language = text.substring(2).trim();
1075
+ return;
1076
+ }
1077
+ if (text.startsWith('@T')) {
1078
+ structure.title += (structure.title ? ' / ' : '') + text.substring(2).trim();
1079
+ return;
1080
+ }
1081
+ if (text.startsWith('@') ||
1082
+ text.startsWith('(') ||
1083
+ text.startsWith('PART') ||
1084
+ /^\d+\s+\d+/.test(text.trim())) {
1085
+ return;
1086
+ }
1087
+ if (/^(Verse|Chorus|Bridge|Break|Intro|End\.)/i.test(text.trim())) {
1088
+ const isExplicitCut = text.startsWith('\\') || text.startsWith('/');
1089
+ const isNaturalTransition = currentLineBlocks.length === 0 || (event.tick - lastBlockTick > 500);
1090
+ if (isExplicitCut || isNaturalTransition) {
1091
+ if (currentLineBlocks.length > 0) {
1092
+ currentParaLines.push({ tick: currentLineBlocks[0].tick, blocks: currentLineBlocks });
1093
+ currentLineBlocks = [];
1094
+ }
1095
+ if (currentParaLines.length > 0) {
1096
+ while (currentParaLines.length > 4) {
1097
+ const linesToPush = currentParaLines.splice(0, 4);
1098
+ paragraphs.push({ tick: linesToPush[0].tick, lines: linesToPush });
1099
+ }
1100
+ if (currentParaLines.length > 0) {
1101
+ paragraphs.push({ tick: currentParaLines[0].tick, lines: currentParaLines });
1102
+ currentParaLines = [];
1103
+ }
1104
+ }
1105
+ }
1106
+ return;
1107
+ }
1108
+ let forceNewLine = false;
1109
+ if (currentLineBlocks.length > 0) {
1110
+ const prevBlock = currentLineBlocks[currentLineBlocks.length - 1];
1111
+ const prevText = prevBlock.text;
1112
+ const currentTrimmed = text.trimLeft();
1113
+ if (currentTrimmed.length > 0) {
1114
+ const isCapitalized = /^[A-Z]/.test(currentTrimmed) || /^'[A-Z]/.test(currentTrimmed);
1115
+ const prevIsCapitalized = /^[A-Z]/.test( prevText.trim()) || /^'[A-Z]/.test( prevText.trim());
1116
+ if (isCapitalized && !prevText.endsWith(' ') && prevText.trim() != 'o' && !prevIsCapitalized) {
1117
+ if (event.tick > lastBlockTick) {
1118
+ forceNewLine = true;
1119
+ }
1120
+ }
1121
+ if (text.startsWith('"') && prevText.endsWith('"')) {
1122
+ forceNewLine = true;
1123
+ }
1124
+ if (text.startsWith('"') && (prevText.endsWith(')') || prevText.endsWith(')"'))) {
1125
+ forceNewLine = true;
1126
+ }
1127
+ if (currentTrimmed.startsWith('"') && prevText.trimRight().endsWith(')')) {
1128
+ forceNewLine = true;
1129
+ }
1130
+ }
1131
+ }
1132
+ const isNewParagraphMarker = text.startsWith('\\');
1133
+ const isNewLineMarker = text.startsWith('/') || forceNewLine;
1134
+ if (isNewParagraphMarker || isNewLineMarker) {
1135
+ if (text.startsWith('\\') || text.startsWith('/')) {
1136
+ text = text.substring(1);
1137
+ }
1138
+ }
1139
+ text = text.replace(/[\r\n]/g, "");
1140
+ let isTimeGapTrigger = false;
1141
+ if (lastBlockTick > 0 && event.tick > lastBlockTick) {
1142
+ const secondsSilence = this.ticksToSeconds(lastBlockTick, event.tick);
1143
+ if (secondsSilence > 2.5) {
1144
+ isTimeGapTrigger = true;
1145
+ }
1146
+ }
1147
+ const currentLineChars = currentLineBlocks.reduce((sum, b) => sum + b.text.length, 0);
1148
+ const isWordLimitTrigger = currentLineChars + text.length > this.#opts.maxCharPerLine;
1149
+
1150
+ if (isNewLineMarker || isNewParagraphMarker || isTimeGapTrigger || isWordLimitTrigger) {
1151
+ if (currentLineBlocks.length > 0) {
1152
+ currentParaLines.push({ tick: currentLineBlocks[0].tick, blocks: currentLineBlocks });
1153
+ currentLineBlocks = [];
1154
+ }
1155
+ if (currentParaLines.length > 0) {
1156
+ if (isNewParagraphMarker || isTimeGapTrigger) {
1157
+ while (currentParaLines.length > 4) {
1158
+ const linesToPush = currentParaLines.splice(0, 4);
1159
+ paragraphs.push({ tick: linesToPush[0].tick, lines: linesToPush });
1160
+ }
1161
+ if (currentParaLines.length > 0) {
1162
+ paragraphs.push({ tick: currentParaLines[0].tick, lines: currentParaLines });
1163
+ currentParaLines = [];
1164
+ }
1165
+ }
1166
+ else if (currentParaLines.length >= 6) {
1167
+ const linesToPush = currentParaLines.splice(0, 4);
1168
+ paragraphs.push({ tick: linesToPush[0].tick, lines: linesToPush });
1169
+ }
1170
+ }
1171
+ }
1172
+ if (text.length > 0) {
1173
+ currentLineBlocks.push({ text: text, tick: event.tick });
1174
+ lastBlockTick = event.tick;
1175
+ }
1176
+ });
1177
+ if (currentLineBlocks.length > 0) {
1178
+ currentParaLines.push({
1179
+ tick: currentLineBlocks[0].tick,
1180
+ blocks: currentLineBlocks
1181
+ });
1182
+ }
1183
+ while (currentParaLines.length > 4) {
1184
+ const linesToPush = currentParaLines.splice(0, 4);
1185
+ paragraphs.push({ tick: linesToPush[0].tick, lines: linesToPush });
1186
+ }
1187
+ if (currentParaLines.length > 0) {
1188
+ paragraphs.push({ tick: currentParaLines[0].tick, lines: currentParaLines });
1189
+ }
1190
+ paragraphs = paragraphs.filter(p => {
1191
+ return !(p.lines.length == 1 && p.lines[0].blocks.length == 1 && (['intro', 'outro', 'sfx', 'solo', 'chorus', 'verse', 'bridge', 'break', 'end'].includes(p.lines[0].blocks[0].text.toLowerCase().trim())));
1192
+ });
1193
+ if(paragraphs.length <= 2) paragraphs = [];
1194
+ structure.paragraphs = paragraphs;
1195
+ this.#lyrics = structure;
1196
+ return structure;
1197
+ }
1198
+
1199
+
1200
+ async #generateKaraokeFrames() {
1201
+ const lyrics = await this.#extractLyrics();
1202
+ if (!lyrics.paragraphs.length) {
1203
+ this.#haveLyrics = false;
1204
+ this.events[MidiAudioPlayer.KARAOKE_CHANNEL].push({
1205
+ text: `<span class="karaoke-intro"></span>`,
1206
+ name: 'Karaoke Event',
1207
+ type: 'intro',
1208
+ tick: 0,
1209
+ channel: MidiAudioPlayer.KARAOKE_CHANNEL,
1210
+ });
1211
+ this.events[MidiAudioPlayer.KARAOKE_CHANNEL] = this.events[MidiAudioPlayer.KARAOKE_CHANNEL].sort((a, b) => a.tick - b.tick);
1212
+ return;
1213
+ }
1214
+ this.#haveLyrics = true;
1215
+ this.#title = lyrics.title;
1216
+ let lastFrameEnd = 0;
1217
+ const delayTicks = this.secondsToTicks(this.#opts.karaokeDelay);
1218
+ const threeSecondsInTicks = this.secondsToTicks(3);
1219
+ const fiveSecondsInTicks = this.secondsToTicks(5);
1220
+ const sevenSecondsInTicks = this.secondsToTicks(7);
1221
+ const tenSecondsInTicks = this.secondsToTicks(10);
1222
+ const allBlocksInSong = [];
1223
+ lyrics.paragraphs.forEach((p, pIdx) => {
1224
+ p.lines.forEach((l, lIdx) => {
1225
+ l.blocks.forEach(b => {
1226
+ allBlocksInSong.push({
1227
+ block: b,
1228
+ lineIdx: lIdx,
1229
+ paraIdx: pIdx,
1230
+ paragraph: p,
1231
+ fastLinesText: p.lines.map(li => li.blocks.map(bl => bl.text).join(''))
1232
+ });
1233
+ });
1234
+ });
1235
+ });
1236
+ const paragraphDisplayTicks = [];
1237
+ lyrics.paragraphs.forEach((p, pIdx) => {
1238
+ let paragraphDisplayTick = this.getTickBeforeSeconds(p.tick, 5);
1239
+ if (paragraphDisplayTick < lastFrameEnd)
1240
+ paragraphDisplayTick = lastFrameEnd + ((p.tick - lastFrameEnd) / 2);
1241
+ if (pIdx === 0 && paragraphDisplayTick < 20)
1242
+ paragraphDisplayTick = 20;
1243
+ paragraphDisplayTicks[pIdx] = paragraphDisplayTick;
1244
+ const fastLinesText = p.lines.map(li => li.blocks.map(b => b.text).join(''));
1245
+ const initialHTML = fastLinesText
1246
+ .map(lineText => `<span class="karaoke-coming">${lineText}</span>`)
1247
+ .join('<br/>');
1248
+ this.events[MidiAudioPlayer.KARAOKE_CHANNEL].push({
1249
+ text: initialHTML,
1250
+ name: 'Karaoke Event',
1251
+ type: 'lyric',
1252
+ tick: paragraphDisplayTick,
1253
+ channel: MidiAudioPlayer.KARAOKE_CHANNEL,
1254
+ });
1255
+ if (p.lines.length > 0) {
1256
+ const lastLine = p.lines[p.lines.length - 1];
1257
+ if (lastLine.blocks.length > 0)
1258
+ lastFrameEnd = lastLine.blocks[lastLine.blocks.length - 1].tick;
1259
+ }
1260
+ });
1261
+ const firstParaDisplayTick = paragraphDisplayTicks[0] || 0;
1262
+ if (firstParaDisplayTick > 25) {
1263
+ this.events[MidiAudioPlayer.KARAOKE_CHANNEL].push({
1264
+ text: `<span class="karaoke-clear"></span>`,
1265
+ name: 'Karaoke Event',
1266
+ type: 'clear',
1267
+ tick: 5,
1268
+ channel: MidiAudioPlayer.KARAOKE_CHANNEL,
1269
+ });
1270
+ }
1271
+ allBlocksInSong.forEach((current, index) => {
1272
+ const currentBlock = current.block;
1273
+ const currentLineIdx = current.lineIdx;
1274
+ const currentParaIdx = current.paraIdx;
1275
+ const p = current.paragraph;
1276
+ const fastLinesText = current.fastLinesText;
1277
+ const generateHTML = (forceAllPlayedOnActiveLine = false) => {
1278
+ return p.lines.map((li, liIdx) => {
1279
+ if (liIdx < currentLineIdx)
1280
+ return `<span class="karaoke-played">${fastLinesText[liIdx]}</span>`;
1281
+ if (liIdx > currentLineIdx)
1282
+ return `<span class="karaoke-coming">${fastLinesText[liIdx]}</span>`;
1283
+ let lineHTML = '';
1284
+ li.blocks.forEach(block => {
1285
+ let className = 'coming';
1286
+ if (forceAllPlayedOnActiveLine || block.tick < currentBlock.tick)
1287
+ className = 'played';
1288
+ else if (block.tick === currentBlock.tick)
1289
+ className = 'playing';
1290
+ lineHTML += `<span class="karaoke-${className}">${block.text}</span>`;
1291
+ });
1292
+ return lineHTML;
1293
+ }).join('<br>');
1294
+ };
1295
+ this.events[MidiAudioPlayer.KARAOKE_CHANNEL].push({
1296
+ text: generateHTML(false),
1297
+ name: 'Karaoke Event',
1298
+ type: 'lyric',
1299
+ tick: currentBlock.tick - delayTicks,
1300
+ channel: MidiAudioPlayer.KARAOKE_CHANNEL,
1301
+ });
1302
+ const next = allBlocksInSong[index + 1];
1303
+ if (next) {
1304
+ const tickDifference = next.block.tick - currentBlock.tick;
1305
+ if (tickDifference > threeSecondsInTicks) {
1306
+ let targetCleanupTick = currentBlock.tick + threeSecondsInTicks;
1307
+ let targetClearTick = currentBlock.tick + sevenSecondsInTicks;
1308
+ let shouldAddClear = tickDifference > tenSecondsInTicks && currentParaIdx > 0;
1309
+ if (next.paraIdx !== currentParaIdx) {
1310
+ const nextParaDisplayTick = paragraphDisplayTicks[next.paraIdx];
1311
+ if (targetCleanupTick >= nextParaDisplayTick)
1312
+ targetCleanupTick = nextParaDisplayTick - 1;
1313
+ if (shouldAddClear)
1314
+ if (targetClearTick >= nextParaDisplayTick || (nextParaDisplayTick - targetClearTick) < threeSecondsInTicks)
1315
+ shouldAddClear = false;
1316
+ }
1317
+ if (targetCleanupTick > currentBlock.tick) {
1318
+ this.events[MidiAudioPlayer.KARAOKE_CHANNEL].push({
1319
+ text: generateHTML(true),
1320
+ name: 'Karaoke Event',
1321
+ type: 'lyric',
1322
+ tick: targetCleanupTick - delayTicks,
1323
+ channel: MidiAudioPlayer.KARAOKE_CHANNEL,
1324
+ });
1325
+ }
1326
+ if (shouldAddClear && targetClearTick > targetCleanupTick) {
1327
+ this.events[MidiAudioPlayer.KARAOKE_CHANNEL].push({
1328
+ text: `<span class="karaoke-clear"></span>`,
1329
+ name: 'Karaoke Event',
1330
+ type: 'clear',
1331
+ tick: targetClearTick - delayTicks,
1332
+ channel: MidiAudioPlayer.KARAOKE_CHANNEL,
1333
+ });
1334
+ }
1335
+ }
1336
+ }
1337
+ lastFrameEnd = currentBlock.tick;
1338
+ });
1339
+ if ((this.totalTicks - lastFrameEnd) > this.secondsToTicks(5)) {
1340
+ this.events[MidiAudioPlayer.KARAOKE_CHANNEL].push({
1341
+ text: `<span class="karaoke-clear"></span>`,
1342
+ name: 'Karaoke Event',
1343
+ type: 'clear',
1344
+ tick: lastFrameEnd + this.secondsToTicks(5),
1345
+ channel: MidiAudioPlayer.KARAOKE_CHANNEL,
1346
+ });
1347
+ } else {
1348
+ this.events[MidiAudioPlayer.KARAOKE_CHANNEL].push({
1349
+ text: `<span class="karaoke-clear"></span>`,
1350
+ name: 'Karaoke Event',
1351
+ type: 'clear',
1352
+ tick: this.totalTicks - 1,
1353
+ channel: MidiAudioPlayer.KARAOKE_CHANNEL,
1354
+ });
1355
+ }
1356
+ this.events[MidiAudioPlayer.KARAOKE_CHANNEL] = this.events[MidiAudioPlayer.KARAOKE_CHANNEL].sort((a, b) => a.tick - b.tick);
1357
+ }
1358
+
1359
+
1360
+ async #detectKaraokeVocalChannel() {
1361
+ const lyrics = await this.#extractLyrics();
1362
+ if (!lyrics?.paragraphs?.length) return null;
1363
+ const textTicks = lyrics.paragraphs
1364
+ .flatMap(p => p.lines.flatMap(l => l.blocks.map(b => b.tick)));
1365
+ if (textTicks.length === 0) return null;
1366
+ const tickTolerance = this.division ? (this.division / 2) : 48;
1367
+ const VOCAL_MIN = 48;
1368
+ const VOCAL_MAX = 84;
1369
+ const channelsToScan = Object.keys(this.#channels)
1370
+ .map(Number)
1371
+ .filter(chan => chan !== 10);
1372
+ let bestChannel = null;
1373
+ let bestScore = -Infinity;
1374
+ for (const channel of channelsToScan) {
1375
+ const notes = this.events.flatMap(track =>
1376
+ track.filter(e =>
1377
+ e.name === 'Note on' &&
1378
+ e.velocity > 0 &&
1379
+ e.channel === channel
1380
+ )
1381
+ );
1382
+ if (notes.length === 0) continue;
1383
+ const aligned = textTicks.filter(t =>
1384
+ notes.some(n => Math.abs(n.tick - t) <= tickTolerance)
1385
+ ).length;
1386
+ const alignmentScore = aligned / textTicks.length;
1387
+ const notesInRange = notes.filter(n => n.noteNumber >= VOCAL_MIN && n.noteNumber <= VOCAL_MAX);
1388
+ const rangeScore = notesInRange.length / notes.length;
1389
+ if (rangeScore < 0.30) continue;
1390
+ const sorted = [...notes].sort((a, b) => a.tick - b.tick);
1391
+ const minGap = (this.division / 8) || 6;
1392
+ const poly = sorted.filter((n, i) => i > 0 && Math.abs(n.tick - sorted[i - 1].tick) < minGap).length;
1393
+ const monophonyScore = 1 - poly / Math.max(notes.length - 1, 1);
1394
+ const densityRatio = notes.length / Math.max(textTicks.length, 1);
1395
+ const densityScore = densityRatio < 0.3 ? densityRatio / 0.3
1396
+ : densityRatio > 5 ? Math.max(0, 1 - (densityRatio - 5) / 10)
1397
+ : 1.0;
1398
+ const score = (alignmentScore * 0.45)
1399
+ + (rangeScore * 0.35)
1400
+ + (monophonyScore * 0.15)
1401
+ + (densityScore * 0.05);
1402
+ if (score > bestScore) {
1403
+ bestScore = score;
1404
+ bestChannel = channel;
1405
+ }
1406
+ }
1407
+ return bestScore >= 0.40 ? bestChannel : null;
1408
+ }
1409
+
1410
+
1411
+ #decodeKaraokeString(str) {
1412
+ if (!str) return '';
1413
+ const bytes = new Uint8Array(str.length);
1414
+ for (let i = 0; i < str.length; i++) bytes[i] = str.charCodeAt(i) & 0xff;
1415
+ const decoder = new TextDecoder('windows-1252');
1416
+ let decoded = decoder.decode(bytes);
1417
+ decoded = decoded.replace(/ÿ/g, '');
1418
+ decoded = decoded.replace(/’/g, "'");
1419
+ decoded = decoded.replace(/`/g, "'");
1420
+ return decoded;
1421
+ }
1422
+
1423
+
1424
+ #sendKaraokeFrame(type = 'clear', text = '') {
1425
+ const html = `<span class="karaoke-${type}">${text.replace(/\s\/\s/g, '<br>')}</span>`;
1426
+ if(this.#opts.karaoke) {
1427
+ if(type == 'title') queueMicrotask(() => this.triggerPlayerEvent('karaoke', { type: type, title: text, html: html}));
1428
+ else queueMicrotask(() => this.triggerPlayerEvent('karaoke', { type: type, html: html}));
1429
+ }
1430
+ }
1431
+
1432
+
1433
+ #log(str, err = false) {
1434
+ queueMicrotask(() => this.triggerPlayerEvent('logs', str));
1435
+ }
1436
+
1437
+ }