@karaplay/file-coder 1.5.0 β 1.5.2
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_ENHANCED.md +207 -134
- package/DOCUMENTATION_INDEX.md +317 -0
- package/EMK_REFERENCE_DATA.json +190 -0
- package/EMK_SONGS_INFO.md +336 -0
- package/EMK_TEST_SUITE_README.md +456 -0
- package/EMK_TEST_SUITE_SUMMARY.txt +197 -0
- package/README.md +90 -0
- package/RELEASE_v1.5.1.md +190 -0
- package/RELEASE_v1.5.2.md +238 -0
- package/SONG_LIST.txt +268 -0
- package/TEMPO_TRICKS_SUMMARY.md +240 -0
- package/demo-libs/KarFile.js +391 -0
- package/demo-libs/MIDIEvents.js +325 -0
- package/demo-libs/MIDIFile.js +450 -0
- package/demo-libs/MIDIFileHeader.js +144 -0
- package/demo-libs/MIDIFileTrack.js +111 -0
- package/demo-libs/TextEncoding.js +275 -0
- package/demo-libs/UTF8.js +151 -0
- package/demo-server.js +78 -1
- package/demo-simple.html +287 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -1
- package/dist/kar-validator.d.ts +66 -0
- package/dist/kar-validator.js +152 -0
- package/dist/ncntokar.browser.js +13 -1
- package/dist/ncntokar.js +13 -1
- package/package.json +4 -1
- package/verify-emk-reference.js +230 -0
- package/analyze-emk-cursor.js +0 -169
- package/analyze-emk-simple.js +0 -124
- package/check-real-duration.js +0 -69
- package/temp/test_output.kar +0 -0
- package/test-all-emk-durations.js +0 -109
- package/test-convert-001.js +0 -130
package/demo-simple.html
CHANGED
|
@@ -300,10 +300,10 @@
|
|
|
300
300
|
<h1>π€ EMK to KAR Conversion Demo</h1>
|
|
301
301
|
<p>
|
|
302
302
|
Testing <strong>@karaplay/file-coder</strong>
|
|
303
|
-
<span class="version">v1.
|
|
303
|
+
<span class="version">v1.5.0</span>
|
|
304
304
|
</p>
|
|
305
305
|
<p style="margin-top: 10px; color: #666;">
|
|
306
|
-
β¨ ZXIO Format
|
|
306
|
+
β¨ ZXIO Format Duration Fix (1.24x) | MThd Format (4x) | Simple Audio Playback
|
|
307
307
|
</p>
|
|
308
308
|
</div>
|
|
309
309
|
|
|
@@ -365,9 +365,9 @@
|
|
|
365
365
|
<h3 style="color: #667eea; margin-top: 25px; margin-bottom: 10px;">π Comparison: EMK vs KAR</h3>
|
|
366
366
|
<div class="status info" style="margin-bottom: 15px;">
|
|
367
367
|
<strong>π‘ Why Duration Decreases:</strong><br>
|
|
368
|
-
When tempo increases (Γ
|
|
369
|
-
<strong>Formula:</strong> Duration Ratio Γ Tempo Ratio = 0.
|
|
370
|
-
<strong>This is correct!</strong> The MIDI notes are the same, but they play faster at higher BPM.
|
|
368
|
+
When tempo increases (Γ1.24), the music plays faster, so the total duration decreases proportionally (Γ0.81).<br>
|
|
369
|
+
<strong>Formula:</strong> Duration Ratio Γ Tempo Ratio = 0.81 Γ 1.24 β 1.0 β
<br>
|
|
370
|
+
<strong>This is correct!</strong> The MIDI notes are the same, but they play faster at higher BPM. The converted KAR now matches the original song duration (4:42-4:45).
|
|
371
371
|
</div>
|
|
372
372
|
<table class="comparison-table">
|
|
373
373
|
<thead>
|
|
@@ -384,6 +384,35 @@
|
|
|
384
384
|
</div>
|
|
385
385
|
|
|
386
386
|
<div id="statusContainer"></div>
|
|
387
|
+
|
|
388
|
+
<!-- MIDI Player -->
|
|
389
|
+
<div id="playerContainer" style="display: none; margin-top: 25px;">
|
|
390
|
+
<h3 style="color: #667eea; margin-bottom: 15px;">π΅ MIDI Player</h3>
|
|
391
|
+
<div class="card" style="padding: 20px;">
|
|
392
|
+
<div style="display: flex; align-items: center; gap: 15px; margin-bottom: 15px;">
|
|
393
|
+
<button id="playBtn" class="convert-btn" style="flex: 0 0 auto; min-width: 100px;">
|
|
394
|
+
βΆοΈ Play
|
|
395
|
+
</button>
|
|
396
|
+
<button id="pauseBtn" class="convert-btn" style="flex: 0 0 auto; min-width: 100px; display: none;">
|
|
397
|
+
βΈοΈ Pause
|
|
398
|
+
</button>
|
|
399
|
+
<button id="stopBtn" class="convert-btn" style="flex: 0 0 auto; min-width: 100px;">
|
|
400
|
+
βΉοΈ Stop
|
|
401
|
+
</button>
|
|
402
|
+
<div style="flex: 1;">
|
|
403
|
+
<div style="font-size: 14px; color: #666; margin-bottom: 5px;">
|
|
404
|
+
<span id="currentTime">0:00</span> / <span id="totalTime">0:00</span>
|
|
405
|
+
</div>
|
|
406
|
+
<div style="width: 100%; height: 6px; background: #e0e0e0; border-radius: 3px; overflow: hidden;">
|
|
407
|
+
<div id="progressBar" style="width: 0%; height: 100%; background: linear-gradient(90deg, #667eea, #764ba2); transition: width 0.1s;"></div>
|
|
408
|
+
</div>
|
|
409
|
+
</div>
|
|
410
|
+
</div>
|
|
411
|
+
<div id="playerStatus" style="font-size: 14px; color: #666; padding: 10px; background: #f8f9fa; border-radius: 6px;">
|
|
412
|
+
β³ Ready to play...
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
</div>
|
|
387
416
|
</div>
|
|
388
417
|
</div>
|
|
389
418
|
</div>
|
|
@@ -507,6 +536,10 @@
|
|
|
507
536
|
const convertBtn = document.getElementById('convertBtn');
|
|
508
537
|
const downloadBtn = document.getElementById('downloadBtn');
|
|
509
538
|
|
|
539
|
+
// Stop and hide player
|
|
540
|
+
stopMidi();
|
|
541
|
+
document.getElementById('playerContainer').style.display = 'none';
|
|
542
|
+
|
|
510
543
|
convertBtn.disabled = true;
|
|
511
544
|
convertBtn.innerHTML = '<div class="loading"></div> Converting...';
|
|
512
545
|
|
|
@@ -554,6 +587,10 @@
|
|
|
554
587
|
document.getElementById('lyricsDisplay').textContent = 'β
Conversion Complete!';
|
|
555
588
|
downloadBtn.disabled = false;
|
|
556
589
|
|
|
590
|
+
// Show player
|
|
591
|
+
document.getElementById('playerContainer').style.display = 'block';
|
|
592
|
+
midiPlayer.parsedData = null; // Reset to force re-parse
|
|
593
|
+
|
|
557
594
|
} else {
|
|
558
595
|
showStatus('error', `β Conversion failed!<br>${data.error}`);
|
|
559
596
|
document.getElementById('lyricsDisplay').textContent = 'Conversion failed';
|
|
@@ -664,7 +701,7 @@
|
|
|
664
701
|
emk: '-',
|
|
665
702
|
kar: `${tempoRatio.toFixed(2)}x`,
|
|
666
703
|
change: emkInfo.format === 'ZXIO' ?
|
|
667
|
-
(Math.abs(tempoRatio -
|
|
704
|
+
(Math.abs(tempoRatio - 1.24) < 0.1 ? 'β
Correct (1.24x)' : 'β οΈ Unexpected') :
|
|
668
705
|
(Math.abs(tempoRatio - 4) < 0.5 ? 'β
Correct (~4x)' : 'β οΈ Unexpected')
|
|
669
706
|
}
|
|
670
707
|
];
|
|
@@ -701,9 +738,252 @@
|
|
|
701
738
|
container.innerHTML = `<div class="status ${type}">${message}</div>`;
|
|
702
739
|
}
|
|
703
740
|
|
|
704
|
-
//
|
|
741
|
+
// =================================================================
|
|
742
|
+
// SIMPLE MIDI PLAYER (using karaoke-player libs + Web Audio API)
|
|
743
|
+
// =================================================================
|
|
744
|
+
let midiPlayer = {
|
|
745
|
+
ac: null,
|
|
746
|
+
parsedData: null,
|
|
747
|
+
isPlaying: false,
|
|
748
|
+
isPaused: false,
|
|
749
|
+
startTime: 0,
|
|
750
|
+
pausedTime: 0,
|
|
751
|
+
activeOscillators: {},
|
|
752
|
+
updateInterval: null,
|
|
753
|
+
totalDuration: 0
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
function initMidiPlayer() {
|
|
757
|
+
if (!midiPlayer.ac) {
|
|
758
|
+
midiPlayer.ac = new (window.AudioContext || window.webkitAudioContext)();
|
|
759
|
+
}
|
|
760
|
+
document.getElementById('playerStatus').innerHTML = 'β
Ready to play!';
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
async function parseMidiData(base64Data) {
|
|
764
|
+
try {
|
|
765
|
+
document.getElementById('playerStatus').innerHTML = 'β³ Parsing MIDI...';
|
|
766
|
+
|
|
767
|
+
// Call server to parse using karaoke-player libs
|
|
768
|
+
const response = await fetch('/api/parse-kar', {
|
|
769
|
+
method: 'POST',
|
|
770
|
+
headers: { 'Content-Type': 'application/json' },
|
|
771
|
+
body: JSON.stringify({ karData: base64Data })
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
const data = await response.json();
|
|
775
|
+
|
|
776
|
+
if (!data.success) {
|
|
777
|
+
throw new Error(data.error || 'Failed to parse MIDI');
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
document.getElementById('playerStatus').innerHTML = 'β
MIDI parsed!';
|
|
781
|
+
return data;
|
|
782
|
+
} catch (error) {
|
|
783
|
+
console.error('MIDI parse error:', error);
|
|
784
|
+
throw error;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// MIDI note to frequency conversion
|
|
789
|
+
function midiNoteToFrequency(note) {
|
|
790
|
+
return 440 * Math.pow(2, (note - 69) / 12);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function formatTime(seconds) {
|
|
794
|
+
const mins = Math.floor(seconds / 60);
|
|
795
|
+
const secs = Math.floor(seconds % 60);
|
|
796
|
+
return `${mins}:${String(secs).padStart(2, '0')}`;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
async function playMidi() {
|
|
800
|
+
if (!convertedKarData) {
|
|
801
|
+
alert('Please convert a file first!');
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
try {
|
|
806
|
+
initMidiPlayer();
|
|
807
|
+
|
|
808
|
+
if (!midiPlayer.parsedData) {
|
|
809
|
+
midiPlayer.parsedData = await parseMidiData(convertedKarData);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (midiPlayer.isPaused) {
|
|
813
|
+
resumeMidi();
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Stop any playing notes
|
|
818
|
+
stopMidi();
|
|
819
|
+
|
|
820
|
+
const events = midiPlayer.parsedData.events;
|
|
821
|
+
|
|
822
|
+
// Use duration from server (Tone.js gives accurate duration)
|
|
823
|
+
midiPlayer.totalDuration = midiPlayer.parsedData.duration || 0;
|
|
824
|
+
|
|
825
|
+
// Convert from milliseconds to seconds
|
|
826
|
+
const totalSeconds = midiPlayer.totalDuration / 1000;
|
|
827
|
+
document.getElementById('totalTime').textContent = formatTime(totalSeconds);
|
|
828
|
+
|
|
829
|
+
console.log('π΅ Player Info:');
|
|
830
|
+
console.log(' Events:', events.length);
|
|
831
|
+
console.log(' Duration:', totalSeconds.toFixed(2), 'seconds');
|
|
832
|
+
console.log(' Duration:', formatTime(totalSeconds));
|
|
833
|
+
|
|
834
|
+
// Start playback
|
|
835
|
+
midiPlayer.isPlaying = true;
|
|
836
|
+
midiPlayer.isPaused = false;
|
|
837
|
+
midiPlayer.startTime = midiPlayer.ac.currentTime;
|
|
838
|
+
midiPlayer.activeOscillators = {};
|
|
839
|
+
|
|
840
|
+
document.getElementById('playBtn').style.display = 'none';
|
|
841
|
+
document.getElementById('pauseBtn').style.display = 'block';
|
|
842
|
+
document.getElementById('playerStatus').innerHTML = `βΆοΈ Playing... (${events.length} events)`;
|
|
843
|
+
|
|
844
|
+
// Schedule all notes
|
|
845
|
+
events.forEach(event => {
|
|
846
|
+
const scheduleTime = midiPlayer.startTime + (event.time / 1000); // Convert ms to seconds
|
|
847
|
+
const noteKey = `${event.track}_${event.note}`;
|
|
848
|
+
|
|
849
|
+
// Note On (type 9 = NOTE_ON)
|
|
850
|
+
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(() => {
|
|
885
|
+
if (!midiPlayer.isPlaying) return;
|
|
886
|
+
|
|
887
|
+
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];
|
|
894
|
+
}
|
|
895
|
+
} catch (e) {
|
|
896
|
+
console.error('Note stop error:', e);
|
|
897
|
+
}
|
|
898
|
+
}, Math.max(0, (event.time / 1000) * 1000));
|
|
899
|
+
}
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
// Update progress
|
|
903
|
+
midiPlayer.updateInterval = setInterval(() => {
|
|
904
|
+
if (!midiPlayer.isPlaying) {
|
|
905
|
+
clearInterval(midiPlayer.updateInterval);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
const currentTime = midiPlayer.ac.currentTime - midiPlayer.startTime;
|
|
910
|
+
document.getElementById('currentTime').textContent = formatTime(currentTime);
|
|
911
|
+
|
|
912
|
+
const progress = (currentTime / totalSeconds) * 100;
|
|
913
|
+
document.getElementById('progressBar').style.width = Math.min(progress, 100) + '%';
|
|
914
|
+
|
|
915
|
+
if (currentTime >= totalSeconds + 1) {
|
|
916
|
+
stopMidi();
|
|
917
|
+
}
|
|
918
|
+
}, 100);
|
|
919
|
+
|
|
920
|
+
} catch (error) {
|
|
921
|
+
console.error('Play error:', error);
|
|
922
|
+
document.getElementById('playerStatus').innerHTML = 'β Play error: ' + error.message;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function pauseMidi() {
|
|
927
|
+
if (!midiPlayer.isPlaying) return;
|
|
928
|
+
|
|
929
|
+
midiPlayer.isPaused = true;
|
|
930
|
+
midiPlayer.isPlaying = false;
|
|
931
|
+
midiPlayer.pausedTime = midiPlayer.ac.currentTime - midiPlayer.startTime;
|
|
932
|
+
|
|
933
|
+
// Stop all active oscillators
|
|
934
|
+
Object.values(midiPlayer.activeOscillators).forEach(osc => {
|
|
935
|
+
try {
|
|
936
|
+
osc.oscillator.stop();
|
|
937
|
+
} catch (e) {}
|
|
938
|
+
});
|
|
939
|
+
midiPlayer.activeOscillators = {};
|
|
940
|
+
|
|
941
|
+
document.getElementById('playBtn').style.display = 'block';
|
|
942
|
+
document.getElementById('pauseBtn').style.display = 'none';
|
|
943
|
+
document.getElementById('playerStatus').innerHTML = 'βΈοΈ Paused';
|
|
944
|
+
|
|
945
|
+
if (midiPlayer.updateInterval) {
|
|
946
|
+
clearInterval(midiPlayer.updateInterval);
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function resumeMidi() {
|
|
951
|
+
// For simplicity, restart from beginning
|
|
952
|
+
playMidi();
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function stopMidi() {
|
|
956
|
+
midiPlayer.isPlaying = false;
|
|
957
|
+
midiPlayer.isPaused = false;
|
|
958
|
+
midiPlayer.pausedTime = 0;
|
|
959
|
+
|
|
960
|
+
// Stop all active oscillators
|
|
961
|
+
Object.values(midiPlayer.activeOscillators).forEach(osc => {
|
|
962
|
+
try {
|
|
963
|
+
osc.oscillator.stop();
|
|
964
|
+
} catch (e) {}
|
|
965
|
+
});
|
|
966
|
+
midiPlayer.activeOscillators = {};
|
|
967
|
+
|
|
968
|
+
document.getElementById('playBtn').style.display = 'block';
|
|
969
|
+
document.getElementById('pauseBtn').style.display = 'none';
|
|
970
|
+
document.getElementById('currentTime').textContent = '0:00';
|
|
971
|
+
document.getElementById('progressBar').style.width = '0%';
|
|
972
|
+
document.getElementById('playerStatus').innerHTML = 'βΉοΈ Stopped';
|
|
973
|
+
|
|
974
|
+
if (midiPlayer.updateInterval) {
|
|
975
|
+
clearInterval(midiPlayer.updateInterval);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// =================================================================
|
|
980
|
+
// EVENT LISTENERS
|
|
981
|
+
// =================================================================
|
|
705
982
|
document.getElementById('convertBtn').addEventListener('click', convertFile);
|
|
706
983
|
document.getElementById('downloadBtn').addEventListener('click', downloadKar);
|
|
984
|
+
document.getElementById('playBtn').addEventListener('click', playMidi);
|
|
985
|
+
document.getElementById('pauseBtn').addEventListener('click', pauseMidi);
|
|
986
|
+
document.getElementById('stopBtn').addEventListener('click', stopMidi);
|
|
707
987
|
|
|
708
988
|
// Initialize
|
|
709
989
|
loadFileList();
|
package/dist/index.d.ts
CHANGED
|
@@ -10,3 +10,4 @@ export { decodeEmk as decodeEmkClient, parseSongInfo as parseSongInfoClient, xor
|
|
|
10
10
|
export { convertNcnToKarBrowser, parseLyricBuffer, buildKaraokeTrackBrowser, buildMetadataTracksBrowser, fileToBuffer, downloadBuffer, type BrowserConversionOptions, type BrowserConversionResult, } from './ncntokar.browser';
|
|
11
11
|
export { convertEmkToKarBrowser, convertEmkFileToKar, convertEmkFilesBatch, validateThaiLyricReadabilityBrowser, type BrowserEmkToKarOptions, type BrowserEmkToKarResult, } from './emk-to-kar.browser';
|
|
12
12
|
export { readKarBuffer, validateKarBuffer, extractLyricsFromKarBuffer, readKarFile as readKarFileFromBrowser, type KarFileInfo as BrowserKarFileInfo, type KarTrack as BrowserKarTrack, } from './kar-reader.browser';
|
|
13
|
+
export { validateKarFile as validateKarTempo, compareEmkKarTempo, type KarValidationResult } from './kar-validator';
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Supports .emk (Extreme Karaoke), .kar (MIDI karaoke), and NCN format conversions
|
|
5
5
|
*/
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
-
exports.readKarFileFromBrowser = exports.extractLyricsFromKarBuffer = exports.validateKarBuffer = exports.readKarBuffer = exports.validateThaiLyricReadabilityBrowser = exports.convertEmkFilesBatch = exports.convertEmkFileToKar = exports.convertEmkToKarBrowser = exports.downloadBuffer = exports.fileToBuffer = exports.buildMetadataTracksBrowser = exports.buildKaraokeTrackBrowser = exports.parseLyricBuffer = exports.convertNcnToKarBrowser = exports.looksLikeTextClient = exports.xorDecryptClient = exports.parseSongInfoClient = exports.decodeEmkClient = exports.looksLikeTextServer = exports.xorDecryptServer = exports.parseSongInfoServer = exports.decodeEmkServer = exports.validateThaiLyricReadability = exports.convertEmkToKarBatch = exports.convertEmkToKar = exports.extractLyricsFromKar = exports.validateKarFile = exports.readKarFile = exports.CursorReader = exports.ensureOutputDoesNotExist = exports.ensureReadableFile = exports.endOfTrack = exports.metaEvent = exports.trimLineEndings = exports.splitLinesKeepEndings = exports.readFileTextTIS620 = exports.buildMetadataTracks = exports.buildKaraokeTrack = exports.parseLyricFile = exports.convertWithDefaults = exports.convertNcnToKar = void 0;
|
|
7
|
+
exports.compareEmkKarTempo = exports.validateKarTempo = exports.readKarFileFromBrowser = exports.extractLyricsFromKarBuffer = exports.validateKarBuffer = exports.readKarBuffer = exports.validateThaiLyricReadabilityBrowser = exports.convertEmkFilesBatch = exports.convertEmkFileToKar = exports.convertEmkToKarBrowser = exports.downloadBuffer = exports.fileToBuffer = exports.buildMetadataTracksBrowser = exports.buildKaraokeTrackBrowser = exports.parseLyricBuffer = exports.convertNcnToKarBrowser = exports.looksLikeTextClient = exports.xorDecryptClient = exports.parseSongInfoClient = exports.decodeEmkClient = exports.looksLikeTextServer = exports.xorDecryptServer = exports.parseSongInfoServer = exports.decodeEmkServer = exports.validateThaiLyricReadability = exports.convertEmkToKarBatch = exports.convertEmkToKar = exports.extractLyricsFromKar = exports.validateKarFile = exports.readKarFile = exports.CursorReader = exports.ensureOutputDoesNotExist = exports.ensureReadableFile = exports.endOfTrack = exports.metaEvent = exports.trimLineEndings = exports.splitLinesKeepEndings = exports.readFileTextTIS620 = exports.buildMetadataTracks = exports.buildKaraokeTrack = exports.parseLyricFile = exports.convertWithDefaults = exports.convertNcnToKar = void 0;
|
|
8
8
|
// NCN to KAR converter exports
|
|
9
9
|
var ncntokar_1 = require("./ncntokar");
|
|
10
10
|
Object.defineProperty(exports, "convertNcnToKar", { enumerable: true, get: function () { return ncntokar_1.convertNcnToKar; } });
|
|
@@ -62,3 +62,7 @@ Object.defineProperty(exports, "readKarBuffer", { enumerable: true, get: functio
|
|
|
62
62
|
Object.defineProperty(exports, "validateKarBuffer", { enumerable: true, get: function () { return kar_reader_browser_1.validateKarBuffer; } });
|
|
63
63
|
Object.defineProperty(exports, "extractLyricsFromKarBuffer", { enumerable: true, get: function () { return kar_reader_browser_1.extractLyricsFromKarBuffer; } });
|
|
64
64
|
Object.defineProperty(exports, "readKarFileFromBrowser", { enumerable: true, get: function () { return kar_reader_browser_1.readKarFile; } });
|
|
65
|
+
// KAR tempo/duration validator exports (NEW v1.5.0+)
|
|
66
|
+
var kar_validator_1 = require("./kar-validator");
|
|
67
|
+
Object.defineProperty(exports, "validateKarTempo", { enumerable: true, get: function () { return kar_validator_1.validateKarFile; } });
|
|
68
|
+
Object.defineProperty(exports, "compareEmkKarTempo", { enumerable: true, get: function () { return kar_validator_1.compareEmkKarTempo; } });
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KAR File Validation Utilities
|
|
3
|
+
* Validates tempo, duration, and format accuracy
|
|
4
|
+
*/
|
|
5
|
+
export interface KarValidationResult {
|
|
6
|
+
valid: boolean;
|
|
7
|
+
tempo: number;
|
|
8
|
+
duration: number;
|
|
9
|
+
durationMinutes: string;
|
|
10
|
+
ppq: number;
|
|
11
|
+
trackCount: number;
|
|
12
|
+
noteCount: number;
|
|
13
|
+
format: number;
|
|
14
|
+
warnings: string[];
|
|
15
|
+
errors: string[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Validate KAR file accuracy using Tone.js
|
|
19
|
+
*
|
|
20
|
+
* This function uses @tonejs/midi which handles tempo events correctly,
|
|
21
|
+
* providing accurate duration calculations.
|
|
22
|
+
*
|
|
23
|
+
* @param karBuffer - KAR file buffer
|
|
24
|
+
* @returns Validation results with tempo and duration info
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* import { validateKarFile } from '@karaplay/file-coder';
|
|
29
|
+
* import * as fs from 'fs';
|
|
30
|
+
*
|
|
31
|
+
* const karBuffer = fs.readFileSync('song.kar');
|
|
32
|
+
* const validation = validateKarFile(karBuffer);
|
|
33
|
+
*
|
|
34
|
+
* console.log(`Valid: ${validation.valid}`);
|
|
35
|
+
* console.log(`Tempo: ${validation.tempo} BPM`);
|
|
36
|
+
* console.log(`Duration: ${validation.durationMinutes}`);
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export declare function validateKarFile(karBuffer: Buffer): KarValidationResult;
|
|
40
|
+
/**
|
|
41
|
+
* Compare EMK and converted KAR tempo/duration
|
|
42
|
+
*
|
|
43
|
+
* @param emkBuffer - Original EMK file buffer
|
|
44
|
+
* @param karBuffer - Converted KAR file buffer
|
|
45
|
+
* @returns Comparison results
|
|
46
|
+
*
|
|
47
|
+
* @example
|
|
48
|
+
* ```typescript
|
|
49
|
+
* import { compareEmkKarTempo } from '@karaplay/file-coder';
|
|
50
|
+
*
|
|
51
|
+
* const comparison = compareEmkKarTempo(emkBuffer, karBuffer);
|
|
52
|
+
* console.log(`Tempo ratio: ${comparison.tempoRatio.toFixed(2)}x`);
|
|
53
|
+
* console.log(`Format: ${comparison.format}`);
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export declare function compareEmkKarTempo(emkBuffer: Buffer, karBuffer: Buffer): {
|
|
57
|
+
emkTempo: number;
|
|
58
|
+
karTempo: number;
|
|
59
|
+
tempoRatio: number;
|
|
60
|
+
emkDuration: number;
|
|
61
|
+
karDuration: number;
|
|
62
|
+
durationRatio: number;
|
|
63
|
+
format: string;
|
|
64
|
+
expectedRatio: number;
|
|
65
|
+
ratioMatch: boolean;
|
|
66
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* KAR File Validation Utilities
|
|
4
|
+
* Validates tempo, duration, and format accuracy
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.validateKarFile = validateKarFile;
|
|
8
|
+
exports.compareEmkKarTempo = compareEmkKarTempo;
|
|
9
|
+
/**
|
|
10
|
+
* Validate KAR file accuracy using Tone.js
|
|
11
|
+
*
|
|
12
|
+
* This function uses @tonejs/midi which handles tempo events correctly,
|
|
13
|
+
* providing accurate duration calculations.
|
|
14
|
+
*
|
|
15
|
+
* @param karBuffer - KAR file buffer
|
|
16
|
+
* @returns Validation results with tempo and duration info
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```typescript
|
|
20
|
+
* import { validateKarFile } from '@karaplay/file-coder';
|
|
21
|
+
* import * as fs from 'fs';
|
|
22
|
+
*
|
|
23
|
+
* const karBuffer = fs.readFileSync('song.kar');
|
|
24
|
+
* const validation = validateKarFile(karBuffer);
|
|
25
|
+
*
|
|
26
|
+
* console.log(`Valid: ${validation.valid}`);
|
|
27
|
+
* console.log(`Tempo: ${validation.tempo} BPM`);
|
|
28
|
+
* console.log(`Duration: ${validation.durationMinutes}`);
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
function validateKarFile(karBuffer) {
|
|
32
|
+
const warnings = [];
|
|
33
|
+
const errors = [];
|
|
34
|
+
try {
|
|
35
|
+
// Dynamic import to avoid bundling issues
|
|
36
|
+
const { Midi } = require('@tonejs/midi');
|
|
37
|
+
const midi = new Midi(karBuffer);
|
|
38
|
+
// Get basic info
|
|
39
|
+
const tempo = midi.header.tempos.length > 0 ? midi.header.tempos[0].bpm : 0;
|
|
40
|
+
const duration = midi.duration;
|
|
41
|
+
const ppq = midi.header.ppq;
|
|
42
|
+
const trackCount = midi.tracks.length;
|
|
43
|
+
// Count notes
|
|
44
|
+
let noteCount = 0;
|
|
45
|
+
midi.tracks.forEach((track) => {
|
|
46
|
+
noteCount += track.notes.length;
|
|
47
|
+
});
|
|
48
|
+
// Format duration
|
|
49
|
+
const minutes = Math.floor(duration / 60);
|
|
50
|
+
const seconds = Math.floor(duration % 60);
|
|
51
|
+
const durationMinutes = `${minutes}:${String(seconds).padStart(2, '0')}`;
|
|
52
|
+
// Validation checks
|
|
53
|
+
if (tempo === 0) {
|
|
54
|
+
warnings.push('No tempo event found - may use default 120 BPM');
|
|
55
|
+
}
|
|
56
|
+
if (duration === 0) {
|
|
57
|
+
errors.push('Duration is zero - file may be corrupt or empty');
|
|
58
|
+
}
|
|
59
|
+
if (noteCount === 0) {
|
|
60
|
+
warnings.push('No notes found - file may be empty');
|
|
61
|
+
}
|
|
62
|
+
if (trackCount === 0) {
|
|
63
|
+
errors.push('No tracks found - file may be corrupt');
|
|
64
|
+
}
|
|
65
|
+
// Check for unusual values
|
|
66
|
+
if (tempo > 300) {
|
|
67
|
+
warnings.push(`High tempo detected (${tempo.toFixed(0)} BPM) - verify playback speed`);
|
|
68
|
+
}
|
|
69
|
+
if (duration < 10) {
|
|
70
|
+
warnings.push(`Very short duration (${duration.toFixed(1)}s) - verify timing`);
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
valid: errors.length === 0,
|
|
74
|
+
tempo,
|
|
75
|
+
duration,
|
|
76
|
+
durationMinutes,
|
|
77
|
+
ppq,
|
|
78
|
+
trackCount,
|
|
79
|
+
noteCount,
|
|
80
|
+
format: midi.header.format,
|
|
81
|
+
warnings,
|
|
82
|
+
errors
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
errors.push(`Failed to parse KAR file: ${error?.message || String(error)}`);
|
|
87
|
+
return {
|
|
88
|
+
valid: false,
|
|
89
|
+
tempo: 0,
|
|
90
|
+
duration: 0,
|
|
91
|
+
durationMinutes: '0:00',
|
|
92
|
+
ppq: 0,
|
|
93
|
+
trackCount: 0,
|
|
94
|
+
noteCount: 0,
|
|
95
|
+
format: 0,
|
|
96
|
+
warnings,
|
|
97
|
+
errors
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Compare EMK and converted KAR tempo/duration
|
|
103
|
+
*
|
|
104
|
+
* @param emkBuffer - Original EMK file buffer
|
|
105
|
+
* @param karBuffer - Converted KAR file buffer
|
|
106
|
+
* @returns Comparison results
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* ```typescript
|
|
110
|
+
* import { compareEmkKarTempo } from '@karaplay/file-coder';
|
|
111
|
+
*
|
|
112
|
+
* const comparison = compareEmkKarTempo(emkBuffer, karBuffer);
|
|
113
|
+
* console.log(`Tempo ratio: ${comparison.tempoRatio.toFixed(2)}x`);
|
|
114
|
+
* console.log(`Format: ${comparison.format}`);
|
|
115
|
+
* ```
|
|
116
|
+
*/
|
|
117
|
+
function compareEmkKarTempo(emkBuffer, karBuffer) {
|
|
118
|
+
try {
|
|
119
|
+
const { Midi } = require('@tonejs/midi');
|
|
120
|
+
// Import from index to get proper exports
|
|
121
|
+
const index = require('./index');
|
|
122
|
+
const decodeEmkServer = index.decodeEmkServer || require('./emk/server-decode').decodeEmk;
|
|
123
|
+
// Decode EMK
|
|
124
|
+
const decoded = decodeEmkServer(emkBuffer);
|
|
125
|
+
const emkMidi = new Midi(decoded.midi);
|
|
126
|
+
// Parse KAR
|
|
127
|
+
const karMidi = new Midi(karBuffer);
|
|
128
|
+
const emkTempo = emkMidi.header.tempos[0]?.bpm || 0;
|
|
129
|
+
const karTempo = karMidi.header.tempos[0]?.bpm || 0;
|
|
130
|
+
const tempoRatio = karTempo / emkTempo;
|
|
131
|
+
const emkDuration = emkMidi.duration;
|
|
132
|
+
const karDuration = karMidi.duration;
|
|
133
|
+
const durationRatio = karDuration / emkDuration;
|
|
134
|
+
const format = decoded.isZxioFormat ? 'ZXIO' : 'MThd';
|
|
135
|
+
const expectedRatio = decoded.isZxioFormat ? 1.24 : 4.0;
|
|
136
|
+
const ratioMatch = Math.abs(tempoRatio - expectedRatio) < 0.1;
|
|
137
|
+
return {
|
|
138
|
+
emkTempo,
|
|
139
|
+
karTempo,
|
|
140
|
+
tempoRatio,
|
|
141
|
+
emkDuration,
|
|
142
|
+
karDuration,
|
|
143
|
+
durationRatio,
|
|
144
|
+
format,
|
|
145
|
+
expectedRatio,
|
|
146
|
+
ratioMatch
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
throw new Error(`Failed to compare EMK/KAR: ${error?.message || String(error)}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
package/dist/ncntokar.browser.js
CHANGED
|
@@ -363,7 +363,19 @@ function ensureTracksHaveEndOfTrack(tracks) {
|
|
|
363
363
|
function fixEmkMidiTempoBrowser(midi, ticksPerBeat, isZxioFormat = false) {
|
|
364
364
|
// ZXIO format uses a different time base (PPQ / 77.42 instead of PPQ / 24)
|
|
365
365
|
// This gives a ratio of approximately 1.24x instead of 4x
|
|
366
|
-
|
|
366
|
+
//
|
|
367
|
+
// Special case: Some EMK files (e.g., Z2510006.emk) have PPQ >= 480 and are already
|
|
368
|
+
// properly timed, so they don't need tempo adjustment (ratio = 1x)
|
|
369
|
+
let ratio;
|
|
370
|
+
if (isZxioFormat) {
|
|
371
|
+
ratio = ticksPerBeat / 77.42;
|
|
372
|
+
}
|
|
373
|
+
else if (ticksPerBeat >= 480) {
|
|
374
|
+
ratio = 1.0; // No adjustment needed for high PPQ files
|
|
375
|
+
}
|
|
376
|
+
else {
|
|
377
|
+
ratio = ticksPerBeat / 24; // Standard MThd conversion
|
|
378
|
+
}
|
|
367
379
|
let fixed = 0;
|
|
368
380
|
// Find and fix all tempo events
|
|
369
381
|
if (midi.tracks) {
|
package/dist/ncntokar.js
CHANGED
|
@@ -336,7 +336,19 @@ function ensureTracksHaveEndOfTrack(tracks) {
|
|
|
336
336
|
function fixEmkMidiTempo(midi, ticksPerBeat, isZxioFormat = false) {
|
|
337
337
|
// ZXIO format uses a different time base (PPQ / 77.42 instead of PPQ / 24)
|
|
338
338
|
// This gives a ratio of approximately 1.24x instead of 4x
|
|
339
|
-
|
|
339
|
+
//
|
|
340
|
+
// Special case: Some EMK files (e.g., Z2510006.emk) have PPQ >= 480 and are already
|
|
341
|
+
// properly timed, so they don't need tempo adjustment (ratio = 1x)
|
|
342
|
+
let ratio;
|
|
343
|
+
if (isZxioFormat) {
|
|
344
|
+
ratio = ticksPerBeat / 77.42;
|
|
345
|
+
}
|
|
346
|
+
else if (ticksPerBeat >= 480) {
|
|
347
|
+
ratio = 1.0; // No adjustment needed for high PPQ files
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
ratio = ticksPerBeat / 24; // Standard MThd conversion
|
|
351
|
+
}
|
|
340
352
|
let fixed = 0;
|
|
341
353
|
// Find and fix all tempo events
|
|
342
354
|
if (midi.tracks) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@karaplay/file-coder",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.2",
|
|
4
4
|
"description": "A comprehensive library for encoding/decoding karaoke files (.emk, .kar, MIDI) with Next.js support. Convert EMK to KAR, read/write karaoke files, full browser and server support.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
"test": "node_modules/.bin/jest",
|
|
27
27
|
"test:watch": "node_modules/.bin/jest --watch",
|
|
28
28
|
"test:coverage": "node_modules/.bin/jest --coverage",
|
|
29
|
+
"test:emk": "node verify-emk-reference.js",
|
|
30
|
+
"test:emk:verbose": "node verify-emk-reference.js --verbose",
|
|
31
|
+
"analyze:emk": "node analyze-all-emk.js",
|
|
29
32
|
"prepare": "npm run build",
|
|
30
33
|
"demo": "node demo-server.js"
|
|
31
34
|
},
|