@karaplay/file-coder 1.5.4 → 1.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/demo-simple.html CHANGED
@@ -140,6 +140,30 @@
140
140
  background: #dc3545;
141
141
  }
142
142
 
143
+ .convert-btn {
144
+ background: #667eea;
145
+ color: white;
146
+ border: none;
147
+ padding: 10px 20px;
148
+ border-radius: 6px;
149
+ cursor: pointer;
150
+ font-size: 14px;
151
+ font-weight: 600;
152
+ transition: all 0.3s;
153
+ }
154
+
155
+ .convert-btn:hover:not(:disabled) {
156
+ background: #5568d3;
157
+ transform: translateY(-1px);
158
+ box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
159
+ }
160
+
161
+ .convert-btn:disabled {
162
+ background: #ccc;
163
+ cursor: not-allowed;
164
+ opacity: 0.6;
165
+ }
166
+
143
167
  .player-container {
144
168
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
145
169
  border-radius: 12px;
@@ -150,16 +174,50 @@
150
174
 
151
175
  .lyrics-display {
152
176
  text-align: center;
153
- font-size: 48px;
177
+ font-size: 42px;
154
178
  font-weight: bold;
155
- line-height: 1.4;
179
+ line-height: 1.5;
156
180
  min-height: 150px;
157
181
  display: flex;
158
182
  align-items: center;
159
183
  justify-content: center;
160
184
  color: white;
161
- text-shadow: 2px 2px 8px rgba(0,0,0,0.3);
185
+ text-shadow: 3px 3px 10px rgba(0,0,0,0.5);
162
186
  margin-bottom: 20px;
187
+ padding: 30px;
188
+ transition: all 0.4s ease;
189
+ }
190
+
191
+ .lyrics-display.highlight {
192
+ transform: scale(1.05);
193
+ }
194
+
195
+ .lyric-line {
196
+ width: 100%;
197
+ padding: 15px;
198
+ transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
199
+ animation: fadeIn 0.4s ease-in;
200
+ }
201
+
202
+ .lyric-line.active {
203
+ font-size: 56px;
204
+ color: #ffeb3b;
205
+ text-shadow:
206
+ 0 0 20px rgba(255,235,59,0.9),
207
+ 0 0 40px rgba(255,235,59,0.6),
208
+ 3px 3px 10px rgba(0,0,0,0.8);
209
+ font-weight: 900;
210
+ }
211
+
212
+ @keyframes fadeIn {
213
+ from {
214
+ opacity: 0;
215
+ transform: translateY(20px);
216
+ }
217
+ to {
218
+ opacity: 1;
219
+ transform: translateY(0);
220
+ }
163
221
  }
164
222
 
165
223
  .info-panel {
@@ -293,6 +351,10 @@
293
351
  100% { transform: rotate(360deg); }
294
352
  }
295
353
  </style>
354
+
355
+ <!-- MIDI Player with Soundfont Support -->
356
+ <script src="https://cdn.jsdelivr.net/npm/tone@14.8.49/build/Tone.js"></script>
357
+ <script src="https://cdn.jsdelivr.net/npm/soundfont-player@0.12.0/dist/soundfont-player.min.js"></script>
296
358
  </head>
297
359
  <body>
298
360
  <div class="container">
@@ -303,7 +365,7 @@
303
365
  <span class="version">v1.5.0</span>
304
366
  </p>
305
367
  <p style="margin-top: 10px; color: #666;">
306
- ✨ ZXIO Format Duration Fix (1.24x) | MThd Format (4x) | Simple Audio Playback
368
+ ✨ ZXIO Format Duration Fix (1.24x) | MThd Format (4x) | 🎹 High-Quality Soundfont Audio (GeneralUserGS)
307
369
  </p>
308
370
  </div>
309
371
 
@@ -387,8 +449,12 @@
387
449
 
388
450
  <!-- MIDI Player -->
389
451
  <div id="playerContainer" style="display: none; margin-top: 25px;">
390
- <h3 style="color: #667eea; margin-bottom: 15px;">🎵 MIDI Player</h3>
452
+ <h3 style="color: #667eea; margin-bottom: 15px;">🎵 MIDI Player with Soundfont</h3>
391
453
  <div class="card" style="padding: 20px;">
454
+ <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 12px; border-radius: 6px; margin-bottom: 15px; font-size: 13px;">
455
+ 🎹 <strong>Audio Engine:</strong> High-quality soundfont synthesis (GeneralUserGS)<br>
456
+ ✨ <strong>Enhancement:</strong> Realistic instrument sounds vs basic oscillators
457
+ </div>
392
458
  <div style="display: flex; align-items: center; gap: 15px; margin-bottom: 15px;">
393
459
  <button id="playBtn" class="convert-btn" style="flex: 0 0 auto; min-width: 100px;">
394
460
  ▶️ Play
@@ -423,6 +489,7 @@
423
489
  let convertedKarData = null;
424
490
  let emkInfo = null;
425
491
  let karInfo = null;
492
+ let lyricsData = []; // Store lyrics with timing from karaoke-player
426
493
 
427
494
  // Load EMK files list
428
495
  async function loadFileList() {
@@ -739,25 +806,52 @@
739
806
  }
740
807
 
741
808
  // =================================================================
742
- // SIMPLE MIDI PLAYER (using karaoke-player libs + Web Audio API)
809
+ // MIDI PLAYER with Soundfont Support (GeneralUserGS.sf3)
743
810
  // =================================================================
744
811
  let midiPlayer = {
745
812
  ac: null,
813
+ soundfont: null,
746
814
  parsedData: null,
747
815
  isPlaying: false,
748
816
  isPaused: false,
749
817
  startTime: 0,
750
818
  pausedTime: 0,
751
- activeOscillators: {},
819
+ activeNotes: {},
820
+ scheduledEvents: [],
752
821
  updateInterval: null,
753
- totalDuration: 0
822
+ totalDuration: 0,
823
+ soundfontLoaded: false
754
824
  };
755
825
 
756
- function initMidiPlayer() {
826
+ async function initMidiPlayer() {
757
827
  if (!midiPlayer.ac) {
758
828
  midiPlayer.ac = new (window.AudioContext || window.webkitAudioContext)();
759
829
  }
760
- document.getElementById('playerStatus').innerHTML = '✅ Ready to play!';
830
+
831
+ // Load soundfont if not loaded
832
+ if (!midiPlayer.soundfontLoaded) {
833
+ try {
834
+ document.getElementById('playerStatus').innerHTML = '⏳ Loading soundfont (Piano)...';
835
+
836
+ // Use Soundfont.instrument to load piano
837
+ midiPlayer.soundfont = await Soundfont.instrument(midiPlayer.ac, 'acoustic_grand_piano', {
838
+ soundfont: 'FluidR3_GM',
839
+ format: 'mp3',
840
+ nameToUrl: (name, soundfont, format) => {
841
+ return `https://gleitz.github.io/midi-js-soundfonts/${soundfont}/${name}-${format}.js`;
842
+ }
843
+ });
844
+
845
+ midiPlayer.soundfontLoaded = true;
846
+ document.getElementById('playerStatus').innerHTML = '✅ Soundfont loaded! Ready to play with high-quality audio.';
847
+ } catch (error) {
848
+ console.error('Soundfont load error:', error);
849
+ document.getElementById('playerStatus').innerHTML = '⚠️ Using basic audio (soundfont failed to load)';
850
+ // Will fallback to basic oscillators
851
+ }
852
+ } else {
853
+ document.getElementById('playerStatus').innerHTML = '✅ Ready to play!';
854
+ }
761
855
  }
762
856
 
763
857
  async function parseMidiData(base64Data) {
@@ -777,7 +871,38 @@
777
871
  throw new Error(data.error || 'Failed to parse MIDI');
778
872
  }
779
873
 
780
- document.getElementById('playerStatus').innerHTML = '✅ MIDI parsed!';
874
+ // Store lyrics data
875
+ lyricsData = data.lyrics || [];
876
+ console.log('📝 Lyrics loaded:', lyricsData.length, 'lines');
877
+
878
+ // Debug: Detailed timing table for ALL lyrics
879
+ if (lyricsData.length > 0) {
880
+ console.log('\n' + '='.repeat(80));
881
+ console.log('📊 CLIENT-SIDE LYRIC TIMING (received from server)');
882
+ console.log('='.repeat(80));
883
+ console.log('Line | Start Time | End Time | Duration | Text');
884
+ console.log('-----|------------|------------|----------|' + '-'.repeat(40));
885
+
886
+ for (let i = 0; i < lyricsData.length; i++) {
887
+ const lyric = lyricsData[i];
888
+ const startTime = (lyric.time / 1000).toFixed(3);
889
+
890
+ // Calculate end time
891
+ const nextLyric = lyricsData[i + 1];
892
+ const endTime = nextLyric ? (nextLyric.time / 1000).toFixed(3) : (lyric.time / 1000 + 2).toFixed(3);
893
+ const duration = (parseFloat(endTime) - parseFloat(startTime)).toFixed(3);
894
+
895
+ const text = lyric.text?.replace(/\n/g, ' ').substring(0, 35) || '';
896
+ const lineNum = String(i).padStart(4);
897
+
898
+ console.log(`${lineNum} | ${startTime.padStart(10)}s | ${endTime.padStart(10)}s | ${duration.padStart(8)}s | "${text}"`);
899
+ }
900
+ console.log('='.repeat(80) + '\n');
901
+
902
+ displayLyrics(0);
903
+ }
904
+
905
+ document.getElementById('playerStatus').innerHTML = '✅ MIDI parsed! ' + lyricsData.length + ' lyric lines loaded.';
781
906
  return data;
782
907
  } catch (error) {
783
908
  console.error('MIDI parse error:', error);
@@ -785,6 +910,106 @@
785
910
  }
786
911
  }
787
912
 
913
+ // Display lyrics at specific index (ONE LINE AT A TIME - like real karaoke)
914
+ function displayLyrics(index) {
915
+ const lyricsDisplay = document.getElementById('lyricsDisplay');
916
+
917
+ if (!lyricsData || lyricsData.length === 0) {
918
+ lyricsDisplay.textContent = '♪ Instrumental ♪';
919
+ return;
920
+ }
921
+
922
+ if (index < 0 || index >= lyricsData.length) {
923
+ lyricsDisplay.textContent = '♪ ♪ ♪';
924
+ return;
925
+ }
926
+
927
+ // Show ONLY the current line (one line at a time like real karaoke)
928
+ const line = lyricsData[index];
929
+ let text = line.text || '';
930
+
931
+ // Decode TIS-620 Thai text if it contains Latin-1 characters (À-ÿ)
932
+ if (text && /[\u00C0-\u00FF]/.test(text)) {
933
+ try {
934
+ // TIS-620 to Unicode mapping (Thai characters 0xA0-0xFF)
935
+ const tis620ToUnicode = {
936
+ 0xA0: 0x0E00, 0xA1: 0x0E01, 0xA2: 0x0E02, 0xA3: 0x0E03, 0xA4: 0x0E04, 0xA5: 0x0E05, 0xA6: 0x0E06, 0xA7: 0x0E07,
937
+ 0xA8: 0x0E08, 0xA9: 0x0E09, 0xAA: 0x0E0A, 0xAB: 0x0E0B, 0xAC: 0x0E0C, 0xAD: 0x0E0D, 0xAE: 0x0E0E, 0xAF: 0x0E0F,
938
+ 0xB0: 0x0E10, 0xB1: 0x0E11, 0xB2: 0x0E12, 0xB3: 0x0E13, 0xB4: 0x0E14, 0xB5: 0x0E15, 0xB6: 0x0E16, 0xB7: 0x0E17,
939
+ 0xB8: 0x0E18, 0xB9: 0x0E19, 0xBA: 0x0E1A, 0xBB: 0x0E1B, 0xBC: 0x0E1C, 0xBD: 0x0E1D, 0xBE: 0x0E1E, 0xBF: 0x0E1F,
940
+ 0xC0: 0x0E20, 0xC1: 0x0E21, 0xC2: 0x0E22, 0xC3: 0x0E23, 0xC4: 0x0E24, 0xC5: 0x0E25, 0xC6: 0x0E26, 0xC7: 0x0E27,
941
+ 0xC8: 0x0E28, 0xC9: 0x0E29, 0xCA: 0x0E2A, 0xCB: 0x0E2B, 0xCC: 0x0E2C, 0xCD: 0x0E2D, 0xCE: 0x0E2E, 0xCF: 0x0E2F,
942
+ 0xD0: 0x0E30, 0xD1: 0x0E31, 0xD2: 0x0E32, 0xD3: 0x0E33, 0xD4: 0x0E34, 0xD5: 0x0E35, 0xD6: 0x0E36, 0xD7: 0x0E37,
943
+ 0xD8: 0x0E38, 0xD9: 0x0E39, 0xDA: 0x0E3A, 0xDF: 0x0E3F, 0xE0: 0x0E40, 0xE1: 0x0E41, 0xE2: 0x0E42, 0xE3: 0x0E43,
944
+ 0xE4: 0x0E44, 0xE5: 0x0E45, 0xE6: 0x0E46, 0xE7: 0x0E47, 0xE8: 0x0E48, 0xE9: 0x0E49, 0xEA: 0x0E4A, 0xEB: 0x0E4B,
945
+ 0xEC: 0x0E4C, 0xED: 0x0E4D, 0xEE: 0x0E4E, 0xEF: 0x0E4F, 0xF0: 0x0E50, 0xF1: 0x0E51, 0xF2: 0x0E52, 0xF3: 0x0E53,
946
+ 0xF4: 0x0E54, 0xF5: 0x0E55, 0xF6: 0x0E56, 0xF7: 0x0E57, 0xF8: 0x0E58, 0xF9: 0x0E59, 0xFA: 0x0E5A, 0xFB: 0x0E5B
947
+ };
948
+
949
+ // Convert each character
950
+ let decoded = '';
951
+ for (let i = 0; i < text.length; i++) {
952
+ const code = text.charCodeAt(i);
953
+ if (code >= 0xA0 && tis620ToUnicode[code]) {
954
+ decoded += String.fromCharCode(tis620ToUnicode[code]);
955
+ } else {
956
+ decoded += text[i];
957
+ }
958
+ }
959
+ text = decoded;
960
+ } catch (e) {
961
+ console.warn('Thai decode failed:', e);
962
+ }
963
+ }
964
+
965
+ // Escape HTML
966
+ const safeText = text.replace(/</g, '&lt;').replace(/>/g, '&gt;');
967
+
968
+ // Display single line with active styling
969
+ lyricsDisplay.innerHTML = `<div class="lyric-line active">${safeText.trim() || '♪'}</div>`;
970
+
971
+ // Add highlight effect
972
+ lyricsDisplay.classList.add('highlight');
973
+ setTimeout(() => lyricsDisplay.classList.remove('highlight'), 300);
974
+
975
+ // Log for debugging
976
+ console.log(`🎵 Lyric ${index + 1}/${lyricsData.length}: time=${line.time}ms, text="${text.substring(0, 50)}"`);
977
+ }
978
+
979
+ // Find current lyric index based on time
980
+ function getCurrentLyricIndex(currentTime) {
981
+ if (!lyricsData || lyricsData.length === 0) return -1;
982
+
983
+ // currentTime is in seconds from AudioContext
984
+ // lyrics time is in milliseconds
985
+ const currentTimeMs = currentTime * 1000;
986
+
987
+ // Don't show any lyrics if we're before the first one
988
+ if (currentTimeMs < lyricsData[0].time) {
989
+ return -1;
990
+ }
991
+
992
+ // Find the lyric that should be currently displayed
993
+ // Show a lyric from its time until the next lyric starts
994
+ for (let i = lyricsData.length - 1; i >= 0; i--) {
995
+ if (currentTimeMs >= lyricsData[i].time) {
996
+ // Check if we should still show this lyric or move to next
997
+ // Show each lyric until the next one starts
998
+ if (i < lyricsData.length - 1) {
999
+ // If current time is before next lyric, show current
1000
+ if (currentTimeMs < lyricsData[i + 1].time) {
1001
+ return i;
1002
+ }
1003
+ } else {
1004
+ // Last lyric, show it
1005
+ return i;
1006
+ }
1007
+ }
1008
+ }
1009
+
1010
+ return 0; // Fallback to first lyric
1011
+ }
1012
+
788
1013
  // MIDI note to frequency conversion
789
1014
  function midiNoteToFrequency(note) {
790
1015
  return 440 * Math.pow(2, (note - 69) / 12);
@@ -803,7 +1028,7 @@
803
1028
  }
804
1029
 
805
1030
  try {
806
- initMidiPlayer();
1031
+ await initMidiPlayer();
807
1032
 
808
1033
  if (!midiPlayer.parsedData) {
809
1034
  midiPlayer.parsedData = await parseMidiData(convertedKarData);
@@ -826,80 +1051,102 @@
826
1051
  const totalSeconds = midiPlayer.totalDuration / 1000;
827
1052
  document.getElementById('totalTime').textContent = formatTime(totalSeconds);
828
1053
 
829
- console.log('🎵 Player Info:');
1054
+ console.log('\n' + '='.repeat(80));
1055
+ console.log('🎵 PLAYBACK INFO');
1056
+ console.log('='.repeat(80));
830
1057
  console.log(' Events:', events.length);
831
- console.log(' Duration:', totalSeconds.toFixed(2), 'seconds');
832
- console.log(' Duration:', formatTime(totalSeconds));
1058
+ console.log(' Duration:', totalSeconds.toFixed(2), 's (', formatTime(totalSeconds), ')');
1059
+ console.log(' Soundfont:', midiPlayer.soundfontLoaded ? '✅ Piano (FluidR3_GM)' : '⚠️ Basic audio');
1060
+ console.log(' Audio Latency Compensation:', (midiPlayer.audioLatency || 0).toFixed(3), 's (', ((midiPlayer.audioLatency || 0) * 1000).toFixed(0), 'ms)');
1061
+ console.log(' First event time:', events[0]?.time, 'ms');
1062
+ console.log(' Last event time:', events[events.length - 1]?.time, 'ms');
1063
+ console.log('='.repeat(80) + '\n');
833
1064
 
834
1065
  // Start playback
835
1066
  midiPlayer.isPlaying = true;
836
1067
  midiPlayer.isPaused = false;
837
1068
  midiPlayer.startTime = midiPlayer.ac.currentTime;
838
- midiPlayer.activeOscillators = {};
1069
+ midiPlayer.activeNotes = {};
1070
+ midiPlayer.scheduledEvents = [];
1071
+
1072
+ // Audio latency compensation (soundfont has some delay)
1073
+ // Reduced latency since cursor scaling is now correct (20x)
1074
+ midiPlayer.audioLatency = 0.25; // 250ms delay for lyrics to match actual sound
839
1075
 
840
1076
  document.getElementById('playBtn').style.display = 'none';
841
1077
  document.getElementById('pauseBtn').style.display = 'block';
842
- document.getElementById('playerStatus').innerHTML = `▶️ Playing... (${events.length} events)`;
1078
+ const audioType = midiPlayer.soundfontLoaded ? 'High-quality soundfont' : 'Basic audio';
1079
+ document.getElementById('playerStatus').innerHTML = `▶️ Playing with ${audioType}... (${events.length} events)`;
843
1080
 
844
- // Schedule all notes
1081
+ // Schedule all notes using Web Audio API timing (more accurate than setTimeout)
845
1082
  events.forEach(event => {
846
- const scheduleTime = midiPlayer.startTime + (event.time / 1000); // Convert ms to seconds
847
- const noteKey = `${event.track}_${event.note}`;
1083
+ // event.time is in milliseconds, convert to seconds
1084
+ const eventTimeSeconds = event.time / 1000;
1085
+ const scheduleTime = midiPlayer.startTime + eventTimeSeconds;
1086
+ const noteKey = `${event.track}_${event.note}_${event.time}`;
848
1087
 
849
1088
  // Note On (type 9 = NOTE_ON)
850
1089
  if (event.type === 9 && event.velocity > 0) {
851
- // Schedule note start
852
- setTimeout(() => {
853
- if (!midiPlayer.isPlaying) return;
854
-
855
- try {
856
- // Create oscillator
857
- const oscillator = midiPlayer.ac.createOscillator();
858
- const gainNode = midiPlayer.ac.createGain();
859
-
860
- oscillator.connect(gainNode);
861
- gainNode.connect(midiPlayer.ac.destination);
862
-
863
- // Set frequency from MIDI note
864
- oscillator.frequency.value = midiNoteToFrequency(event.note);
865
- oscillator.type = 'sine';
866
-
867
- // Set volume from velocity
868
- gainNode.gain.value = (event.velocity / 127) * 0.3; // Scale down volume
869
-
870
- // Start oscillator
871
- oscillator.start(scheduleTime);
872
-
873
- // Store for note off
874
- midiPlayer.activeOscillators[noteKey] = { oscillator, gainNode, startTime: scheduleTime };
875
- } catch (e) {
876
- console.error('Note start error:', e);
877
- }
878
- }, Math.max(0, (event.time / 1000) * 1000));
879
- }
880
-
881
- // Note Off (type 8 = NOTE_OFF or type 9 with velocity 0)
882
- if (event.type === 8 || (event.type === 9 && event.velocity === 0)) {
883
- // Schedule note stop
884
- setTimeout(() => {
1090
+ // Calculate note duration (find matching note off)
1091
+ const noteOffEvent = events.find(e =>
1092
+ e.note === event.note &&
1093
+ e.track === event.track &&
1094
+ e.time > event.time &&
1095
+ (e.type === 8 || (e.type === 9 && e.velocity === 0))
1096
+ );
1097
+
1098
+ // Duration in seconds
1099
+ const durationSeconds = noteOffEvent ? (noteOffEvent.time - event.time) / 1000 : 0.5;
1100
+
1101
+ // Use setTimeout with CORRECT timing (milliseconds from now)
1102
+ const delayMs = Math.max(0, eventTimeSeconds * 1000);
1103
+
1104
+ const timeoutId = setTimeout(() => {
885
1105
  if (!midiPlayer.isPlaying) return;
886
1106
 
887
1107
  try {
888
- const osc = midiPlayer.activeOscillators[noteKey];
889
- if (osc) {
890
- // Fade out
891
- osc.gainNode.gain.exponentialRampToValueAtTime(0.01, scheduleTime + 0.1);
892
- osc.oscillator.stop(scheduleTime + 0.1);
893
- delete midiPlayer.activeOscillators[noteKey];
1108
+ if (midiPlayer.soundfont && midiPlayer.soundfontLoaded) {
1109
+ // Use soundfont for high-quality audio
1110
+ const midiNote = event.note;
1111
+ const velocity = event.velocity / 127;
1112
+
1113
+ // Play note with soundfont at the correct time
1114
+ const note = midiPlayer.soundfont.play(midiNote, scheduleTime, {
1115
+ duration: durationSeconds,
1116
+ gain: velocity * 0.8 // Adjust volume
1117
+ });
1118
+
1119
+ midiPlayer.activeNotes[noteKey] = note;
1120
+ } else {
1121
+ // Fallback to basic oscillator with Web Audio scheduling
1122
+ const oscillator = midiPlayer.ac.createOscillator();
1123
+ const gainNode = midiPlayer.ac.createGain();
1124
+
1125
+ oscillator.connect(gainNode);
1126
+ gainNode.connect(midiPlayer.ac.destination);
1127
+
1128
+ oscillator.frequency.value = midiNoteToFrequency(event.note);
1129
+ oscillator.type = 'sine';
1130
+ gainNode.gain.value = (event.velocity / 127) * 0.3;
1131
+
1132
+ // Schedule using Web Audio API (more accurate)
1133
+ oscillator.start(scheduleTime);
1134
+ oscillator.stop(scheduleTime + durationSeconds);
1135
+
1136
+ midiPlayer.activeNotes[noteKey] = { oscillator, gainNode };
894
1137
  }
895
1138
  } catch (e) {
896
- console.error('Note stop error:', e);
1139
+ console.error('Note play error:', e);
897
1140
  }
898
- }, Math.max(0, (event.time / 1000) * 1000));
1141
+ }, delayMs);
1142
+
1143
+ midiPlayer.scheduledEvents.push(timeoutId);
899
1144
  }
900
1145
  });
901
1146
 
902
- // Update progress
1147
+ // Update progress and lyrics
1148
+ let lastLyricIndex = -1;
1149
+ let debugCounter = 0;
903
1150
  midiPlayer.updateInterval = setInterval(() => {
904
1151
  if (!midiPlayer.isPlaying) {
905
1152
  clearInterval(midiPlayer.updateInterval);
@@ -912,6 +1159,30 @@
912
1159
  const progress = (currentTime / totalSeconds) * 100;
913
1160
  document.getElementById('progressBar').style.width = Math.min(progress, 100) + '%';
914
1161
 
1162
+ // Update lyrics display (check every frame)
1163
+ // Subtract audio latency to delay lyrics to match actual sound
1164
+ const lyricTime = currentTime - (midiPlayer.audioLatency || 0);
1165
+ const currentLyricIndex = getCurrentLyricIndex(lyricTime);
1166
+
1167
+ // Debug logging every 2 seconds
1168
+ debugCounter++;
1169
+ if (debugCounter % 20 === 0) {
1170
+ const lyricText = lyricsData[currentLyricIndex]?.text?.substring(0, 30) || '(none)';
1171
+ const lyricStartTime = lyricsData[currentLyricIndex]?.time / 1000 || 0;
1172
+ console.log(`⏱️ Playback: ${currentTime.toFixed(2)}s | Lyric Time (adjusted): ${lyricTime.toFixed(2)}s | Index: ${currentLyricIndex}/${lyricsData.length} | Lyric Start: ${lyricStartTime.toFixed(2)}s | "${lyricText}"`);
1173
+ }
1174
+
1175
+ // Always update if index changed (including -1 for "before first lyric")
1176
+ if (currentLyricIndex !== lastLyricIndex) {
1177
+ if (currentLyricIndex >= 0) {
1178
+ displayLyrics(currentLyricIndex);
1179
+ } else {
1180
+ // Before first lyric, show waiting message
1181
+ document.getElementById('lyricsDisplay').innerHTML = '<div class="lyric-line">♪ ♪ ♪</div>';
1182
+ }
1183
+ lastLyricIndex = currentLyricIndex;
1184
+ }
1185
+
915
1186
  if (currentTime >= totalSeconds + 1) {
916
1187
  stopMidi();
917
1188
  }
@@ -930,13 +1201,23 @@
930
1201
  midiPlayer.isPlaying = false;
931
1202
  midiPlayer.pausedTime = midiPlayer.ac.currentTime - midiPlayer.startTime;
932
1203
 
933
- // Stop all active oscillators
934
- Object.values(midiPlayer.activeOscillators).forEach(osc => {
1204
+ // Stop all active notes
1205
+ Object.values(midiPlayer.activeNotes).forEach(note => {
935
1206
  try {
936
- osc.oscillator.stop();
1207
+ if (note && note.stop) {
1208
+ note.stop();
1209
+ } else if (note && note.oscillator) {
1210
+ note.oscillator.stop();
1211
+ }
937
1212
  } catch (e) {}
938
1213
  });
939
- midiPlayer.activeOscillators = {};
1214
+ midiPlayer.activeNotes = {};
1215
+
1216
+ // Clear scheduled events
1217
+ midiPlayer.scheduledEvents.forEach(timeoutId => {
1218
+ clearTimeout(timeoutId);
1219
+ });
1220
+ midiPlayer.scheduledEvents = [];
940
1221
 
941
1222
  document.getElementById('playBtn').style.display = 'block';
942
1223
  document.getElementById('pauseBtn').style.display = 'none';
@@ -957,13 +1238,23 @@
957
1238
  midiPlayer.isPaused = false;
958
1239
  midiPlayer.pausedTime = 0;
959
1240
 
960
- // Stop all active oscillators
961
- Object.values(midiPlayer.activeOscillators).forEach(osc => {
1241
+ // Stop all active notes
1242
+ Object.values(midiPlayer.activeNotes).forEach(note => {
962
1243
  try {
963
- osc.oscillator.stop();
1244
+ if (note && note.stop) {
1245
+ note.stop();
1246
+ } else if (note && note.oscillator) {
1247
+ note.oscillator.stop();
1248
+ }
964
1249
  } catch (e) {}
965
1250
  });
966
- midiPlayer.activeOscillators = {};
1251
+ midiPlayer.activeNotes = {};
1252
+
1253
+ // Clear scheduled events
1254
+ midiPlayer.scheduledEvents.forEach(timeoutId => {
1255
+ clearTimeout(timeoutId);
1256
+ });
1257
+ midiPlayer.scheduledEvents = [];
967
1258
 
968
1259
  document.getElementById('playBtn').style.display = 'block';
969
1260
  document.getElementById('pauseBtn').style.display = 'none';
@@ -971,6 +1262,11 @@
971
1262
  document.getElementById('progressBar').style.width = '0%';
972
1263
  document.getElementById('playerStatus').innerHTML = '⏹️ Stopped';
973
1264
 
1265
+ // Reset lyrics to first line
1266
+ if (lyricsData && lyricsData.length > 0) {
1267
+ displayLyrics(0);
1268
+ }
1269
+
974
1270
  if (midiPlayer.updateInterval) {
975
1271
  clearInterval(midiPlayer.updateInterval);
976
1272
  }
@@ -33,6 +33,57 @@ function looksLikeText(buf) {
33
33
  }
34
34
  return true;
35
35
  }
36
+ /**
37
+ * Reconstruct MIDI file from headerless format (browser version)
38
+ * Identical to server version but works in browser environment
39
+ */
40
+ function reconstructMidiFromTracks(data) {
41
+ const customHeaderLength = 12;
42
+ let format = 1;
43
+ let ticksPerBeat = 96;
44
+ if (data.length >= customHeaderLength) {
45
+ try {
46
+ const possibleFormat = data.readUInt16BE(8);
47
+ const possiblePPQ = data.readUInt16BE(10);
48
+ if (possibleFormat <= 2 && possiblePPQ >= 24 && possiblePPQ <= 960) {
49
+ format = possibleFormat;
50
+ ticksPerBeat = possiblePPQ;
51
+ }
52
+ }
53
+ catch (err) {
54
+ // Use defaults
55
+ }
56
+ }
57
+ const trackData = [];
58
+ let offset = 0;
59
+ while (offset < data.length - 4) {
60
+ const signature = data.toString('ascii', offset, offset + 4);
61
+ if (signature === 'MTrk') {
62
+ const trackLength = data.readUInt32BE(offset + 4);
63
+ const trackEnd = offset + 8 + trackLength;
64
+ if (trackEnd <= data.length) {
65
+ trackData.push(data.subarray(offset, trackEnd));
66
+ offset = trackEnd;
67
+ }
68
+ else {
69
+ break;
70
+ }
71
+ }
72
+ else {
73
+ offset++;
74
+ }
75
+ }
76
+ if (trackData.length === 0) {
77
+ throw new Error('No MTrk blocks found in headerless MIDI data');
78
+ }
79
+ const midiHeader = Buffer.alloc(14);
80
+ midiHeader.write('MThd', 0, 'ascii');
81
+ midiHeader.writeUInt32BE(6, 4);
82
+ midiHeader.writeUInt16BE(format, 8);
83
+ midiHeader.writeUInt16BE(trackData.length, 10);
84
+ midiHeader.writeUInt16BE(ticksPerBeat, 12);
85
+ return Buffer.concat([midiHeader, ...trackData]);
86
+ }
36
87
  /**
37
88
  * Convert custom "zxio" MIDI format to standard MIDI format
38
89
  * Same as server version but works in browser environment
@@ -117,6 +168,11 @@ function decodeEmk(fileBuffer) {
117
168
  result.midi = convertZxioToMidi(inflated);
118
169
  result.isZxioFormat = true; // Mark as zxio format for special handling
119
170
  }
171
+ else if (inflated.indexOf('MTrk') >= 0 && inflated.indexOf('MThd') === -1) {
172
+ // Handle custom format with MTrk blocks but no MThd header (e.g., files starting with "OOn,")
173
+ result.midi = reconstructMidiFromTracks(inflated);
174
+ result.isZxioFormat = false; // Not zxio, but similar custom format
175
+ }
120
176
  else if (looksLikeText(inflated)) {
121
177
  if (!result.lyric) {
122
178
  result.lyric = inflated;