@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/EMK_REFERENCE_DATA.json +110 -188
- package/PLAYBACK_SPEED_FIX.md +223 -0
- package/README.md +25 -0
- package/RELEASE_v1.5.5.md +217 -0
- package/SONG_LIST.txt +334 -251
- package/TEST_DEMO.md +186 -0
- package/VERIFICATION_RESULT.md +116 -0
- package/debug-lyric-timing.js +219 -0
- package/debug-output.txt +2962 -0
- package/demo-client.html +6 -1
- package/demo-server.js +92 -4
- package/demo-simple.html +367 -71
- package/dist/emk/client-decoder.js +56 -0
- package/dist/emk/server-decode.js +70 -0
- package/dist/ncntokar.js +15 -17
- package/download-soundfonts.js +108 -0
- package/full-debug-output.txt +2971 -0
- package/full-debug-timing.js +256 -0
- package/package.json +4 -2
- package/restart-demo.js +105 -0
- package/songs/soundfonts/GeneralUserGS.sf3 +0 -0
- package/start-demo.sh +12 -0
- package/stop-demo.js +84 -0
- package/temp/convert-result.json +1 -0
- package/temp/debug-test.kar +0 -0
- package/temp/kar-data.txt +1 -0
- package/temp/kar-data2.txt +1 -0
- package/temp/kar-test.txt +1 -0
- package/test-browser-thai.html +88 -0
- package/test-kar-timing.js +249 -0
- package/test-playback-speed.js +133 -0
- package/verify-all-songs-duration.js +285 -0
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:
|
|
177
|
+
font-size: 42px;
|
|
154
178
|
font-weight: bold;
|
|
155
|
-
line-height: 1.
|
|
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:
|
|
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) |
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, '<').replace(/>/g, '>');
|
|
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('
|
|
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), '
|
|
832
|
-
console.log('
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
847
|
-
const
|
|
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
|
-
//
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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
|
|
1139
|
+
console.error('Note play error:', e);
|
|
897
1140
|
}
|
|
898
|
-
},
|
|
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
|
|
934
|
-
Object.values(midiPlayer.
|
|
1204
|
+
// Stop all active notes
|
|
1205
|
+
Object.values(midiPlayer.activeNotes).forEach(note => {
|
|
935
1206
|
try {
|
|
936
|
-
|
|
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.
|
|
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
|
|
961
|
-
Object.values(midiPlayer.
|
|
1241
|
+
// Stop all active notes
|
|
1242
|
+
Object.values(midiPlayer.activeNotes).forEach(note => {
|
|
962
1243
|
try {
|
|
963
|
-
|
|
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.
|
|
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;
|