@karaplay/file-coder 1.4.7 ā 1.4.8
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/RELEASE_v1.4.8.md +115 -0
- package/analyze-failed-emk.js +156 -0
- package/dist/emk/client-decoder.d.ts +1 -0
- package/dist/emk/client-decoder.js +1 -0
- package/dist/emk/server-decode.d.ts +1 -0
- package/dist/emk/server-decode.js +1 -0
- package/dist/emk-to-kar.browser.js +4 -1
- package/dist/emk-to-kar.js +6 -1
- package/dist/ncntokar.browser.d.ts +2 -1
- package/dist/ncntokar.browser.js +8 -3
- package/dist/ncntokar.d.ts +2 -1
- package/dist/ncntokar.js +7 -3
- package/find-midi-in-block.js +96 -0
- package/package.json +1 -1
- package/test-final.js +0 -175
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Release v1.4.8 - ZXIO Format Tempo Fix
|
|
2
|
+
|
|
3
|
+
## š Critical Bug Fix: ZXIO Format Tempo & Timing Synchronization
|
|
4
|
+
|
|
5
|
+
### Problem
|
|
6
|
+
After implementing zxio format support in v1.4.7, lyrics were not synchronized with music playback. The tempo fix (introduced in v1.4.5) was incorrectly applied to zxio format files, causing lyrics to play at the wrong speed relative to the music.
|
|
7
|
+
|
|
8
|
+
**Symptoms:**
|
|
9
|
+
- Lyrics faster than music (when tempo was over-adjusted)
|
|
10
|
+
- Lyrics out of sync with melody
|
|
11
|
+
|
|
12
|
+
### Root Cause
|
|
13
|
+
The zxio format stores MIDI data with **correct tempo already embedded**. Unlike standard MThd format which needs tempo adjustment, zxio format files should NOT have their tempo multiplied.
|
|
14
|
+
|
|
15
|
+
When tempo fix was applied to zxio files:
|
|
16
|
+
- Original tempo: 64 BPM
|
|
17
|
+
- Incorrectly adjusted to: 256 BPM (64 Ć 4)
|
|
18
|
+
- Result: **Music played too fast, lyrics out of sync**
|
|
19
|
+
|
|
20
|
+
### Solution
|
|
21
|
+
Implemented format-specific tempo handling:
|
|
22
|
+
|
|
23
|
+
**ZXIO Format:**
|
|
24
|
+
- ā
NO tempo adjustment
|
|
25
|
+
- ā
Normal cursor timing multiplication
|
|
26
|
+
- ā
Result: Perfect synchronization (-20 ticks = 0.05 seconds lead time)
|
|
27
|
+
|
|
28
|
+
**MThd Format (Standard EMK):**
|
|
29
|
+
- ā
Apply tempo fix (multiply by ratio)
|
|
30
|
+
- ā
Skip cursor multiplication (use raw values)
|
|
31
|
+
- ā
Result: Proper tempo for standard files
|
|
32
|
+
|
|
33
|
+
### Technical Details
|
|
34
|
+
|
|
35
|
+
**Changes:**
|
|
36
|
+
1. Added `isZxioFormat` flag to `DecodedEmkParts` interface
|
|
37
|
+
2. Detection: Set flag when zxio format is encountered during decode
|
|
38
|
+
3. Conditional logic: Apply tempo fix only for MThd format
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
// In emk-to-kar.ts
|
|
42
|
+
const needsTempoFix = !decoded.isZxioFormat;
|
|
43
|
+
|
|
44
|
+
convertNcnToKar({
|
|
45
|
+
fixEmkTempo: needsTempoFix, // Only for MThd format
|
|
46
|
+
skipCursorMultiply: needsTempoFix // Only when tempo is fixed
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Testing Results
|
|
51
|
+
|
|
52
|
+
**ZXIO Format Files (e.g., failed01.emk, 001.emk):**
|
|
53
|
+
```
|
|
54
|
+
ā
Tempo: 64 BPM (correct - not multiplied)
|
|
55
|
+
ā
Timing: -20 ticks (0.05 seconds)
|
|
56
|
+
ā
Status: Perfect synchronization
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Before Fix:**
|
|
60
|
+
```
|
|
61
|
+
ā Tempo: 256 BPM (incorrect - 4x multiplied)
|
|
62
|
+
ā Timing: -365 ticks (0.89 seconds)
|
|
63
|
+
ā Status: Lyrics too fast
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Files Modified
|
|
67
|
+
- ā
`src/emk/server-decode.ts` - Added `isZxioFormat` flag
|
|
68
|
+
- ā
`src/emk/client-decoder.ts` - Browser version of flag
|
|
69
|
+
- ā
`src/emk-to-kar.ts` - Conditional tempo fix logic
|
|
70
|
+
- ā
`src/emk-to-kar.browser.ts` - Browser version
|
|
71
|
+
- ā
`src/ncntokar.ts` - `skipCursorMultiply` option
|
|
72
|
+
- ā
`src/ncntokar.browser.ts` - Browser version
|
|
73
|
+
- ā
`package.json` - Version bump to 1.4.8
|
|
74
|
+
|
|
75
|
+
### Usage
|
|
76
|
+
|
|
77
|
+
**No code changes needed!** The library automatically detects file format:
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
import { convertEmkToKar } from '@karaplay/file-coder';
|
|
81
|
+
|
|
82
|
+
// Works correctly for both formats
|
|
83
|
+
const result = convertEmkToKar({
|
|
84
|
+
inputEmk: 'song.emk',
|
|
85
|
+
outputKar: 'song.kar'
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ⨠zxio: No tempo adjustment, perfect sync
|
|
89
|
+
// ⨠MThd: Tempo adjusted, proper timing
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Compatibility
|
|
93
|
+
- ā
Backward compatible with v1.4.7
|
|
94
|
+
- ā
Fixes timing issues for zxio format
|
|
95
|
+
- ā
Maintains proper handling for MThd format
|
|
96
|
+
- ā
Works in both Node.js and browser
|
|
97
|
+
- ā
No breaking changes
|
|
98
|
+
|
|
99
|
+
### Migration
|
|
100
|
+
ā
**Zero migration required** - Transparent bug fix
|
|
101
|
+
|
|
102
|
+
### Known Limitations
|
|
103
|
+
- Standard MThd format files (Z251xxxx.emk) may still have timing variations depending on the source file's cursor data quality
|
|
104
|
+
- This is expected behavior and not related to the zxio format fix
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
**Full Changelog:** v1.4.7...v1.4.8
|
|
109
|
+
|
|
110
|
+
**Install:**
|
|
111
|
+
```bash
|
|
112
|
+
npm install @karaplay/file-coder@1.4.8
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Issue Fixed:** Lyrics timing synchronization for zxio format EMK files
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const { inflateSync } = require('zlib');
|
|
3
|
+
|
|
4
|
+
const XOR_KEY = Buffer.from([0xAF, 0xF2, 0x4C, 0x9C, 0xE9, 0xEA, 0x99, 0x43]);
|
|
5
|
+
const MAGIC_SIGNATURE = '.SFDS';
|
|
6
|
+
const ZLIB_SECOND_BYTES = new Set([0x01, 0x5E, 0x9C, 0xDA, 0x7D, 0x20, 0xBB]);
|
|
7
|
+
|
|
8
|
+
function xorDecrypt(data) {
|
|
9
|
+
const decrypted = Buffer.alloc(data.length);
|
|
10
|
+
for (let i = 0; i < data.length; i++) {
|
|
11
|
+
decrypted[i] = data[i] ^ XOR_KEY[i % XOR_KEY.length];
|
|
12
|
+
}
|
|
13
|
+
return decrypted;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function looksLikeText(buf) {
|
|
17
|
+
const sample = buf.subarray(0, Math.min(64, buf.length));
|
|
18
|
+
for (let i = 0; i < sample.length; i++) {
|
|
19
|
+
const c = sample[i];
|
|
20
|
+
if (c === 0x0a || c === 0x0d || c === 0x09 || (c >= 0x20 && c <= 0x7e) || c >= 0x80) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
console.log('='.repeat(80));
|
|
29
|
+
console.log('ANALYZING failed01.emk STRUCTURE');
|
|
30
|
+
console.log('='.repeat(80));
|
|
31
|
+
console.log('');
|
|
32
|
+
|
|
33
|
+
const emkBuffer = fs.readFileSync('./songs/emk/failed01.emk');
|
|
34
|
+
console.log(`File size: ${emkBuffer.length} bytes`);
|
|
35
|
+
console.log('');
|
|
36
|
+
|
|
37
|
+
// Decrypt
|
|
38
|
+
console.log('Step 1: Decrypting...');
|
|
39
|
+
const decryptedBuffer = xorDecrypt(emkBuffer);
|
|
40
|
+
|
|
41
|
+
const magic = decryptedBuffer.subarray(0, MAGIC_SIGNATURE.length).toString('utf-8');
|
|
42
|
+
console.log(`Magic signature: "${magic}" ${magic === MAGIC_SIGNATURE ? 'ā' : 'ā'}`);
|
|
43
|
+
|
|
44
|
+
if (magic !== MAGIC_SIGNATURE) {
|
|
45
|
+
console.log('ā Invalid EMK file signature!');
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.log('');
|
|
50
|
+
console.log('Step 2: Finding zlib blocks...');
|
|
51
|
+
console.log('');
|
|
52
|
+
|
|
53
|
+
const inflatedParts = [];
|
|
54
|
+
let blockIndex = 0;
|
|
55
|
+
|
|
56
|
+
for (let i = 0; i < decryptedBuffer.length - 2; i++) {
|
|
57
|
+
const b0 = decryptedBuffer[i];
|
|
58
|
+
const b1 = decryptedBuffer[i + 1];
|
|
59
|
+
|
|
60
|
+
if (b0 !== 0x78 || !ZLIB_SECOND_BYTES.has(b1)) continue;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const inflated = inflateSync(decryptedBuffer.subarray(i));
|
|
64
|
+
blockIndex++;
|
|
65
|
+
|
|
66
|
+
console.log(`Block ${blockIndex} at offset ${i}:`);
|
|
67
|
+
console.log(` Compressed from: ${i}`);
|
|
68
|
+
console.log(` Inflated size: ${inflated.length} bytes`);
|
|
69
|
+
|
|
70
|
+
const asciiPrefix = inflated.subarray(0, Math.min(32, inflated.length)).toString('ascii', 0, 16);
|
|
71
|
+
const hexPrefix = inflated.subarray(0, Math.min(32, inflated.length)).toString('hex').match(/.{1,2}/g).join(' ');
|
|
72
|
+
|
|
73
|
+
console.log(` ASCII prefix: "${asciiPrefix}"`);
|
|
74
|
+
console.log(` HEX prefix: ${hexPrefix}`);
|
|
75
|
+
|
|
76
|
+
let blockType = 'Unknown';
|
|
77
|
+
let isMidi = false;
|
|
78
|
+
|
|
79
|
+
if (asciiPrefix.startsWith('SIGNATURE=')) {
|
|
80
|
+
blockType = 'Header';
|
|
81
|
+
} else if (asciiPrefix.startsWith('CODE=')) {
|
|
82
|
+
blockType = 'SongInfo';
|
|
83
|
+
} else if (inflated.subarray(0, 4).toString('ascii') === 'MThd') {
|
|
84
|
+
blockType = 'MIDI (MThd)';
|
|
85
|
+
isMidi = true;
|
|
86
|
+
} else if (inflated.subarray(0, 4).toString('ascii') === 'RIFF') {
|
|
87
|
+
blockType = 'RIFF (WAV/similar)';
|
|
88
|
+
} else if (looksLikeText(inflated)) {
|
|
89
|
+
blockType = 'Text (Lyric/Cursor)';
|
|
90
|
+
} else {
|
|
91
|
+
blockType = 'Binary (Cursor?)';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log(` Type: ${blockType} ${isMidi ? '***' : ''}`);
|
|
95
|
+
console.log('');
|
|
96
|
+
|
|
97
|
+
inflatedParts.push({
|
|
98
|
+
type: blockType,
|
|
99
|
+
offset: i,
|
|
100
|
+
size: inflated.length,
|
|
101
|
+
data: inflated,
|
|
102
|
+
index: blockIndex
|
|
103
|
+
});
|
|
104
|
+
} catch (err) {
|
|
105
|
+
// Not a valid zlib block, continue
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.log('='.repeat(80));
|
|
111
|
+
console.log('SUMMARY');
|
|
112
|
+
console.log('='.repeat(80));
|
|
113
|
+
console.log(`Total blocks found: ${inflatedParts.length}`);
|
|
114
|
+
|
|
115
|
+
inflatedParts.forEach((block, i) => {
|
|
116
|
+
console.log(`${i+1}. ${block.type.padEnd(25)} - ${block.size.toString().padStart(6)} bytes (offset ${block.offset})`);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Check for MIDI
|
|
120
|
+
const midiBlocks = inflatedParts.filter(p => p.type.includes('MIDI'));
|
|
121
|
+
console.log(`\nMIDI blocks: ${midiBlocks.length}`);
|
|
122
|
+
|
|
123
|
+
if (midiBlocks.length === 0) {
|
|
124
|
+
console.log('\nā NO MIDI BLOCK FOUND!');
|
|
125
|
+
console.log('This is why the conversion fails.');
|
|
126
|
+
console.log('\nPossible reasons:');
|
|
127
|
+
console.log(' 1. The file is corrupted');
|
|
128
|
+
console.log(' 2. The MIDI data is not in standard MThd format');
|
|
129
|
+
console.log(' 3. The zlib compression is different');
|
|
130
|
+
console.log(' 4. The file format is different from standard EMK');
|
|
131
|
+
|
|
132
|
+
// Check if there's any block that might be MIDI but not detected
|
|
133
|
+
console.log('\nChecking for non-standard MIDI formats...');
|
|
134
|
+
inflatedParts.forEach((block, i) => {
|
|
135
|
+
if (block.type === 'Binary (Cursor?)' && block.size > 1000) {
|
|
136
|
+
console.log(`\nBlock ${i+1} (${block.size} bytes) might be MIDI:`);
|
|
137
|
+
const first16 = block.data.subarray(0, 16);
|
|
138
|
+
console.log(` First 16 bytes (hex): ${first16.toString('hex').match(/.{1,2}/g).join(' ')}`);
|
|
139
|
+
console.log(` First 16 bytes (ascii): ${first16.toString('ascii').replace(/[^\x20-\x7E]/g, '.')}`);
|
|
140
|
+
|
|
141
|
+
// Try to find MThd anywhere in the block
|
|
142
|
+
const mthdIndex = block.data.indexOf('MThd');
|
|
143
|
+
if (mthdIndex >= 0) {
|
|
144
|
+
console.log(` ā ļø Found 'MThd' at offset ${mthdIndex} inside this block!`);
|
|
145
|
+
console.log(` This block might contain MIDI data starting at offset ${mthdIndex}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
} else {
|
|
150
|
+
console.log('\nā MIDI block found');
|
|
151
|
+
midiBlocks.forEach(m => {
|
|
152
|
+
console.log(` - Block ${m.index}: ${m.size} bytes at offset ${m.offset}`);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
console.log('');
|
|
@@ -115,6 +115,7 @@ function decodeEmk(fileBuffer) {
|
|
|
115
115
|
else if (inflated.subarray(0, 4).toString('ascii') === 'zxio') {
|
|
116
116
|
// Handle custom "zxio" MIDI format
|
|
117
117
|
result.midi = convertZxioToMidi(inflated);
|
|
118
|
+
result.isZxioFormat = true; // Mark as zxio format for special handling
|
|
118
119
|
}
|
|
119
120
|
else if (looksLikeText(inflated)) {
|
|
120
121
|
if (!result.lyric) {
|
|
@@ -113,6 +113,7 @@ function decodeEmk(fileBuffer) {
|
|
|
113
113
|
// Handle custom "zxio" MIDI format (found in some EMK files)
|
|
114
114
|
// This format has a custom header followed by MIDI tracks
|
|
115
115
|
result.midi = convertZxioToMidi(inflated);
|
|
116
|
+
result.isZxioFormat = true; // Mark as zxio format for special handling
|
|
116
117
|
}
|
|
117
118
|
else if (looksLikeText(inflated)) {
|
|
118
119
|
if (!result.lyric) {
|
|
@@ -43,12 +43,15 @@ function convertEmkToKarBrowser(options) {
|
|
|
43
43
|
};
|
|
44
44
|
console.log(`[Browser] [2/3] Converting to KAR: ${metadata.title} - ${metadata.artist}`);
|
|
45
45
|
// Step 2: Convert NCN buffers to KAR
|
|
46
|
+
// Note: zxio format has correct tempo already, no need to fix
|
|
47
|
+
const needsTempoFix = !decoded.isZxioFormat;
|
|
46
48
|
const conversionResult = (0, ncntokar_browser_1.convertNcnToKarBrowser)({
|
|
47
49
|
midiBuffer: decoded.midi,
|
|
48
50
|
lyricBuffer: decoded.lyric,
|
|
49
51
|
cursorBuffer: decoded.cursor,
|
|
50
52
|
outputFileName: options.outputFileName || 'output.kar',
|
|
51
|
-
fixEmkTempo:
|
|
53
|
+
fixEmkTempo: needsTempoFix, // Fix tempo only for standard MThd format
|
|
54
|
+
skipCursorMultiply: needsTempoFix // Skip cursor multiply only when tempo is fixed
|
|
52
55
|
});
|
|
53
56
|
warnings.push(...conversionResult.warnings);
|
|
54
57
|
console.log(`[Browser] [3/3] ā KAR buffer created`);
|
package/dist/emk-to-kar.js
CHANGED
|
@@ -100,13 +100,18 @@ function convertEmkToKar(options) {
|
|
|
100
100
|
};
|
|
101
101
|
console.log(`[2/3] Converting to KAR: ${metadata.title} - ${metadata.artist}`);
|
|
102
102
|
// Step 2: Convert NCN to KAR
|
|
103
|
+
// Note: zxio format has correct tempo already, no need to fix
|
|
104
|
+
// zxio: no tempo fix, use normal cursor multiply
|
|
105
|
+
// MThd: fix tempo, skip cursor multiply (use raw cursor values)
|
|
106
|
+
const needsTempoFix = !decoded.isZxioFormat;
|
|
103
107
|
const conversionResult = (0, ncntokar_1.convertNcnToKar)({
|
|
104
108
|
inputMidi: midiFile,
|
|
105
109
|
inputLyr: lyricFile,
|
|
106
110
|
inputCur: cursorFile,
|
|
107
111
|
outputKar: options.outputKar,
|
|
108
112
|
appendTitles: options.appendTitles || false,
|
|
109
|
-
fixEmkTempo:
|
|
113
|
+
fixEmkTempo: needsTempoFix, // Fix tempo only for standard MThd format
|
|
114
|
+
skipCursorMultiply: needsTempoFix // Skip cursor multiply only when tempo is fixed (MThd format)
|
|
110
115
|
});
|
|
111
116
|
warnings.push(...conversionResult.warnings);
|
|
112
117
|
console.log(`[3/3] ā KAR file created: ${options.outputKar}`);
|
|
@@ -8,6 +8,7 @@ export interface BrowserConversionOptions {
|
|
|
8
8
|
cursorBuffer: Buffer;
|
|
9
9
|
outputFileName?: string;
|
|
10
10
|
fixEmkTempo?: boolean;
|
|
11
|
+
skipCursorMultiply?: boolean;
|
|
11
12
|
}
|
|
12
13
|
export interface BrowserConversionResult {
|
|
13
14
|
success: boolean;
|
|
@@ -69,7 +70,7 @@ export declare function buildKaraokeTrackBrowser(metadata: {
|
|
|
69
70
|
artist: string;
|
|
70
71
|
fullLyric: string;
|
|
71
72
|
lines: string[];
|
|
72
|
-
}, cursorBuffer: Buffer, ticksPerBeat: number): {
|
|
73
|
+
}, cursorBuffer: Buffer, ticksPerBeat: number, skipCursorMultiply?: boolean): {
|
|
73
74
|
track: any[];
|
|
74
75
|
warnings: string[];
|
|
75
76
|
};
|
package/dist/ncntokar.browser.js
CHANGED
|
@@ -163,7 +163,7 @@ function parseLyricBuffer(lyricBuffer) {
|
|
|
163
163
|
/**
|
|
164
164
|
* Builds karaoke track with timing information (browser version)
|
|
165
165
|
*/
|
|
166
|
-
function buildKaraokeTrackBrowser(metadata, cursorBuffer, ticksPerBeat) {
|
|
166
|
+
function buildKaraokeTrackBrowser(metadata, cursorBuffer, ticksPerBeat, skipCursorMultiply) {
|
|
167
167
|
const karaokeTrack = [];
|
|
168
168
|
const warnings = [];
|
|
169
169
|
karaokeTrack.push(createMetaEvent("trackName", 0, "Words"));
|
|
@@ -206,7 +206,12 @@ function buildKaraokeTrackBrowser(metadata, cursorBuffer, ticksPerBeat) {
|
|
|
206
206
|
warnings.push(`ran out of timing info at line: "${trimmed.substring(0, 30)}..."`);
|
|
207
207
|
break;
|
|
208
208
|
}
|
|
209
|
-
|
|
209
|
+
// Conversion: * (ticksPerBeat / 24)
|
|
210
|
+
// For EMK files with tempo fix applied, skip multiplication to avoid double-adjustment
|
|
211
|
+
if (!skipCursorMultiply) {
|
|
212
|
+
absoluteTimestamp = Math.floor(absoluteTimestamp * (ticksPerBeat / 24));
|
|
213
|
+
}
|
|
214
|
+
// else: use raw cursor values (for EMK with tempo fix)
|
|
210
215
|
if (absoluteTimestamp < previousAbsoluteTimestamp) {
|
|
211
216
|
warnings.push("timestamp out of order - clamping");
|
|
212
217
|
absoluteTimestamp = previousAbsoluteTimestamp;
|
|
@@ -410,7 +415,7 @@ function convertNcnToKarBrowser(options) {
|
|
|
410
415
|
// Parse lyric buffer
|
|
411
416
|
const metadata = parseLyricBuffer(options.lyricBuffer);
|
|
412
417
|
// Build karaoke track
|
|
413
|
-
const { track: karaokeTrack, warnings: karaokeWarnings } = buildKaraokeTrackBrowser(metadata, options.cursorBuffer, ticksPerBeat);
|
|
418
|
+
const { track: karaokeTrack, warnings: karaokeWarnings } = buildKaraokeTrackBrowser(metadata, options.cursorBuffer, ticksPerBeat, options.skipCursorMultiply);
|
|
414
419
|
warnings.push(...karaokeWarnings);
|
|
415
420
|
// Build metadata tracks
|
|
416
421
|
const { lyricTrack, artistTrack, titleTrack } = buildMetadataTracksBrowser(metadata);
|
package/dist/ncntokar.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface ConversionOptions {
|
|
|
11
11
|
appendTitles?: boolean;
|
|
12
12
|
titlesFile?: string;
|
|
13
13
|
fixEmkTempo?: boolean;
|
|
14
|
+
skipCursorMultiply?: boolean;
|
|
14
15
|
}
|
|
15
16
|
export interface SongMetadata {
|
|
16
17
|
title: string;
|
|
@@ -62,7 +63,7 @@ export declare function parseLyricFile(lyricFilePath: string): SongMetadata;
|
|
|
62
63
|
/**
|
|
63
64
|
* Builds karaoke track with timing information
|
|
64
65
|
*/
|
|
65
|
-
export declare function buildKaraokeTrack(metadata: SongMetadata, cursorBuffer: Buffer, ticksPerBeat: number): {
|
|
66
|
+
export declare function buildKaraokeTrack(metadata: SongMetadata, cursorBuffer: Buffer, ticksPerBeat: number, skipCursorMultiply?: boolean): {
|
|
66
67
|
track: any[];
|
|
67
68
|
warnings: string[];
|
|
68
69
|
};
|
package/dist/ncntokar.js
CHANGED
|
@@ -200,7 +200,7 @@ function parseLyricFile(lyricFilePath) {
|
|
|
200
200
|
/**
|
|
201
201
|
* Builds karaoke track with timing information
|
|
202
202
|
*/
|
|
203
|
-
function buildKaraokeTrack(metadata, cursorBuffer, ticksPerBeat) {
|
|
203
|
+
function buildKaraokeTrack(metadata, cursorBuffer, ticksPerBeat, skipCursorMultiply) {
|
|
204
204
|
const karaokeTrack = [];
|
|
205
205
|
const warnings = [];
|
|
206
206
|
karaokeTrack.push(metaEvent("trackName", 0, "Words"));
|
|
@@ -246,7 +246,11 @@ function buildKaraokeTrack(metadata, cursorBuffer, ticksPerBeat) {
|
|
|
246
246
|
break;
|
|
247
247
|
}
|
|
248
248
|
// Conversion: * (ticksPerBeat / 24)
|
|
249
|
-
|
|
249
|
+
// For EMK files with tempo fix applied, skip multiplication to avoid double-adjustment
|
|
250
|
+
if (!skipCursorMultiply) {
|
|
251
|
+
absoluteTimestamp = Math.floor(absoluteTimestamp * (ticksPerBeat / 24));
|
|
252
|
+
}
|
|
253
|
+
// else: use raw cursor values (for EMK with tempo fix)
|
|
250
254
|
if (absoluteTimestamp < previousAbsoluteTimestamp) {
|
|
251
255
|
warnings.push("timestamp out of order - clamping");
|
|
252
256
|
absoluteTimestamp = previousAbsoluteTimestamp;
|
|
@@ -384,7 +388,7 @@ function convertNcnToKar(options) {
|
|
|
384
388
|
const metadata = parseLyricFile(options.inputLyr);
|
|
385
389
|
// Build karaoke track
|
|
386
390
|
const cursorBuffer = fs.readFileSync(options.inputCur);
|
|
387
|
-
const { track: karaokeTrack, warnings: karaokeWarnings } = buildKaraokeTrack(metadata, cursorBuffer, ticksPerBeat);
|
|
391
|
+
const { track: karaokeTrack, warnings: karaokeWarnings } = buildKaraokeTrack(metadata, cursorBuffer, ticksPerBeat, options.skipCursorMultiply);
|
|
388
392
|
warnings.push(...karaokeWarnings);
|
|
389
393
|
// Build metadata tracks
|
|
390
394
|
const { lyricTrack, artistTrack, titleTrack } = buildMetadataTracks(metadata);
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const { inflateSync } = require('zlib');
|
|
3
|
+
const { parseMidi } = require('midi-file');
|
|
4
|
+
|
|
5
|
+
const XOR_KEY = Buffer.from([0xAF, 0xF2, 0x4C, 0x9C, 0xE9, 0xEA, 0x99, 0x43]);
|
|
6
|
+
const MAGIC_SIGNATURE = '.SFDS';
|
|
7
|
+
const ZLIB_SECOND_BYTES = new Set([0x01, 0x5E, 0x9C, 0xDA, 0x7D, 0x20, 0xBB]);
|
|
8
|
+
|
|
9
|
+
function xorDecrypt(data) {
|
|
10
|
+
const decrypted = Buffer.alloc(data.length);
|
|
11
|
+
for (let i = 0; i < data.length; i++) {
|
|
12
|
+
decrypted[i] = data[i] ^ XOR_KEY[i % XOR_KEY.length];
|
|
13
|
+
}
|
|
14
|
+
return decrypted;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
console.log('Finding MIDI in Block 3...\n');
|
|
18
|
+
|
|
19
|
+
const emkBuffer = fs.readFileSync('./songs/emk/failed01.emk');
|
|
20
|
+
const decryptedBuffer = xorDecrypt(emkBuffer);
|
|
21
|
+
|
|
22
|
+
// Find block 3 (offset 387)
|
|
23
|
+
const block3Compressed = decryptedBuffer.subarray(387);
|
|
24
|
+
const block3 = inflateSync(block3Compressed);
|
|
25
|
+
|
|
26
|
+
console.log(`Block 3 size: ${block3.length} bytes`);
|
|
27
|
+
console.log(`First 64 bytes (hex):`);
|
|
28
|
+
console.log(block3.subarray(0, 64).toString('hex').match(/.{1,2}/g).join(' '));
|
|
29
|
+
console.log('');
|
|
30
|
+
|
|
31
|
+
// Search for 'MThd'
|
|
32
|
+
const mthdIndex = block3.indexOf('MThd');
|
|
33
|
+
console.log(`Index of 'MThd': ${mthdIndex}`);
|
|
34
|
+
|
|
35
|
+
if (mthdIndex >= 0) {
|
|
36
|
+
console.log(`\nFound 'MThd' at offset ${mthdIndex}!`);
|
|
37
|
+
console.log(`\nBytes before 'MThd':`);
|
|
38
|
+
console.log(` HEX: ${block3.subarray(0, mthdIndex).toString('hex')}`);
|
|
39
|
+
console.log(` ASCII: ${block3.subarray(0, mthdIndex).toString('ascii')}`);
|
|
40
|
+
console.log(` Length: ${mthdIndex} bytes`);
|
|
41
|
+
|
|
42
|
+
// Extract MIDI starting from MThd
|
|
43
|
+
const midiData = block3.subarray(mthdIndex);
|
|
44
|
+
console.log(`\nMIDI data size: ${midiData.length} bytes`);
|
|
45
|
+
console.log(`First 32 bytes of MIDI:`);
|
|
46
|
+
console.log(midiData.subarray(0, 32).toString('hex').match(/.{1,2}/g).join(' '));
|
|
47
|
+
|
|
48
|
+
// Try to parse it
|
|
49
|
+
try {
|
|
50
|
+
const midi = parseMidi(midiData);
|
|
51
|
+
console.log(`\nā
MIDI parsed successfully!`);
|
|
52
|
+
console.log(` Format: ${midi.header.format}`);
|
|
53
|
+
console.log(` Tracks: ${midi.tracks.length}`);
|
|
54
|
+
console.log(` Ticks per beat: ${midi.header.ticksPerBeat}`);
|
|
55
|
+
|
|
56
|
+
// Check tempo
|
|
57
|
+
let tempoFound = false;
|
|
58
|
+
midi.tracks.forEach((track, idx) => {
|
|
59
|
+
track.forEach(event => {
|
|
60
|
+
if (event.type === 'setTempo' && !tempoFound) {
|
|
61
|
+
const bpm = (60000000 / event.microsecondsPerBeat).toFixed(2);
|
|
62
|
+
console.log(` Initial tempo: ${bpm} BPM (${event.microsecondsPerBeat} µs/beat) in Track ${idx}`);
|
|
63
|
+
tempoFound = true;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!tempoFound) {
|
|
69
|
+
console.log(` ā ļø No tempo events found`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Save the extracted MIDI
|
|
73
|
+
fs.writeFileSync('./songs/emk/failed01-extracted.mid', midiData);
|
|
74
|
+
console.log(`\nā Extracted MIDI saved to: ./songs/emk/failed01-extracted.mid`);
|
|
75
|
+
|
|
76
|
+
} catch (err) {
|
|
77
|
+
console.log(`\nā Failed to parse MIDI: ${err.message}`);
|
|
78
|
+
}
|
|
79
|
+
} else {
|
|
80
|
+
console.log('\nā No MThd signature found in Block 3');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Also check what "zxio" might be
|
|
84
|
+
console.log('\n' + '='.repeat(60));
|
|
85
|
+
console.log('Analysis of header "zxio":');
|
|
86
|
+
console.log('='.repeat(60));
|
|
87
|
+
|
|
88
|
+
const header = block3.subarray(0, 12);
|
|
89
|
+
console.log('Bytes: ' + header.toString('hex').match(/.{1,2}/g).join(' '));
|
|
90
|
+
console.log('ASCII: ' + header.toString('ascii'));
|
|
91
|
+
|
|
92
|
+
// Could be a custom header or file identification
|
|
93
|
+
const possibleMagic = header.subarray(0, 4).toString('ascii');
|
|
94
|
+
console.log(`\nFirst 4 bytes as string: "${possibleMagic}"`);
|
|
95
|
+
console.log('This might be a custom EMK MIDI format identifier.');
|
|
96
|
+
console.log('The standard EMK decoder needs to be updated to handle this format.');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@karaplay/file-coder",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.8",
|
|
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",
|
package/test-final.js
DELETED
|
@@ -1,175 +0,0 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
2
|
-
const { parseMidi } = require('midi-file');
|
|
3
|
-
const { convertEmkToKar } = require('./dist/index.js');
|
|
4
|
-
|
|
5
|
-
console.log('='.repeat(80));
|
|
6
|
-
console.log('FINAL TEMPO FIX VERIFICATION');
|
|
7
|
-
console.log('='.repeat(80));
|
|
8
|
-
console.log('');
|
|
9
|
-
|
|
10
|
-
// Convert EMK to KAR with automatic tempo fix
|
|
11
|
-
const outputKar = './songs/fix/final-converted.kar';
|
|
12
|
-
console.log('Step 1: Converting EMK to KAR with auto tempo fix...\n');
|
|
13
|
-
|
|
14
|
-
const result = convertEmkToKar({
|
|
15
|
-
inputEmk: './songs/fix/song.emk',
|
|
16
|
-
outputKar: outputKar,
|
|
17
|
-
keepIntermediateFiles: false
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
console.log(`\nConversion: ${result.success ? 'ā
SUCCESS' : 'ā FAILED'}`);
|
|
21
|
-
console.log(`Warnings: ${result.warnings.length}`);
|
|
22
|
-
result.warnings.forEach(w => console.log(` - ${w}`));
|
|
23
|
-
|
|
24
|
-
// Compare timing and tempo
|
|
25
|
-
console.log('\n' + '='.repeat(80));
|
|
26
|
-
console.log('COMPARISON');
|
|
27
|
-
console.log('='.repeat(80));
|
|
28
|
-
|
|
29
|
-
const originalMidi = parseMidi(fs.readFileSync('./songs/fix/song.kar'));
|
|
30
|
-
const convertedMidi = parseMidi(fs.readFileSync(outputKar));
|
|
31
|
-
|
|
32
|
-
// Check tempo
|
|
33
|
-
console.log('\n1. Tempo:');
|
|
34
|
-
let originalTempo = null;
|
|
35
|
-
let convertedTempo = null;
|
|
36
|
-
|
|
37
|
-
originalMidi.tracks.forEach(track => {
|
|
38
|
-
track.forEach(event => {
|
|
39
|
-
if (event.type === 'setTempo') {
|
|
40
|
-
originalTempo = (60000000 / event.microsecondsPerBeat).toFixed(2);
|
|
41
|
-
}
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
convertedMidi.tracks.forEach(track => {
|
|
46
|
-
track.forEach(event => {
|
|
47
|
-
if (event.type === 'setTempo') {
|
|
48
|
-
convertedTempo = (60000000 / event.microsecondsPerBeat).toFixed(2);
|
|
49
|
-
}
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
console.log(` Original: ${originalTempo} BPM`);
|
|
54
|
-
console.log(` Converted: ${convertedTempo} BPM`);
|
|
55
|
-
console.log(` Difference: ${((convertedTempo / originalTempo - 1) * 100).toFixed(2)}%`);
|
|
56
|
-
|
|
57
|
-
// Check timing (first 10 lyric events)
|
|
58
|
-
console.log('\n2. Timing (first 10 karaoke events):');
|
|
59
|
-
|
|
60
|
-
function getKaraokeTrack(midi) {
|
|
61
|
-
let maxTextEvents = 0;
|
|
62
|
-
let karaokeTrack = null;
|
|
63
|
-
|
|
64
|
-
midi.tracks.forEach(track => {
|
|
65
|
-
const textEvents = track.filter(e => e.type === 'text');
|
|
66
|
-
if (textEvents.length > maxTextEvents) {
|
|
67
|
-
maxTextEvents = textEvents.length;
|
|
68
|
-
karaokeTrack = track;
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
return karaokeTrack;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const originalKaraokeTrack = getKaraokeTrack(originalMidi);
|
|
76
|
-
const convertedKaraokeTrack = getKaraokeTrack(convertedMidi);
|
|
77
|
-
|
|
78
|
-
let originalAbsolute = 0;
|
|
79
|
-
let convertedAbsolute = 0;
|
|
80
|
-
let originalTimes = [];
|
|
81
|
-
let convertedTimes = [];
|
|
82
|
-
|
|
83
|
-
originalKaraokeTrack.forEach(event => {
|
|
84
|
-
originalAbsolute += event.deltaTime;
|
|
85
|
-
if (event.type === 'text') {
|
|
86
|
-
originalTimes.push(originalAbsolute);
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
convertedKaraokeTrack.forEach(event => {
|
|
91
|
-
convertedAbsolute += event.deltaTime;
|
|
92
|
-
if (event.type === 'text') {
|
|
93
|
-
convertedTimes.push(convertedAbsolute);
|
|
94
|
-
}
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
console.log(' Idx | Original | Converted | Match');
|
|
98
|
-
console.log(' ' + '-'.repeat(40));
|
|
99
|
-
|
|
100
|
-
for (let i = 0; i < Math.min(10, originalTimes.length, convertedTimes.length); i++) {
|
|
101
|
-
const match = originalTimes[i] === convertedTimes[i] ? 'ā' : 'ā';
|
|
102
|
-
console.log(` ${(i+1).toString().padStart(3)} | ${originalTimes[i].toString().padStart(8)} | ${convertedTimes[i].toString().padStart(9)} | ${match}`);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Calculate playback duration
|
|
106
|
-
console.log('\n3. Playback Duration Estimate:');
|
|
107
|
-
|
|
108
|
-
function calculateDuration(midi) {
|
|
109
|
-
const ticksPerBeat = midi.header.ticksPerBeat;
|
|
110
|
-
let microsecondsPerBeat = 500000; // default 120 BPM
|
|
111
|
-
|
|
112
|
-
// Get tempo
|
|
113
|
-
midi.tracks.forEach(track => {
|
|
114
|
-
track.forEach(event => {
|
|
115
|
-
if (event.type === 'setTempo') {
|
|
116
|
-
microsecondsPerBeat = event.microsecondsPerBeat;
|
|
117
|
-
}
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
// Get last event time
|
|
122
|
-
let maxTicks = 0;
|
|
123
|
-
midi.tracks.forEach(track => {
|
|
124
|
-
let currentTicks = 0;
|
|
125
|
-
track.forEach(event => {
|
|
126
|
-
currentTicks += event.deltaTime;
|
|
127
|
-
if (currentTicks > maxTicks) {
|
|
128
|
-
maxTicks = currentTicks;
|
|
129
|
-
}
|
|
130
|
-
});
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
// Calculate duration
|
|
134
|
-
const microsecondsPerTick = microsecondsPerBeat / ticksPerBeat;
|
|
135
|
-
const durationMicroseconds = maxTicks * microsecondsPerTick;
|
|
136
|
-
const durationSeconds = durationMicroseconds / 1000000;
|
|
137
|
-
|
|
138
|
-
return {
|
|
139
|
-
ticks: maxTicks,
|
|
140
|
-
seconds: durationSeconds,
|
|
141
|
-
minutes: Math.floor(durationSeconds / 60),
|
|
142
|
-
remainingSeconds: Math.floor(durationSeconds % 60)
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
const originalDuration = calculateDuration(originalMidi);
|
|
147
|
-
const convertedDuration = calculateDuration(convertedMidi);
|
|
148
|
-
|
|
149
|
-
console.log(` Original: ${originalDuration.minutes}:${originalDuration.remainingSeconds.toString().padStart(2, '0')} (${originalDuration.ticks} ticks)`);
|
|
150
|
-
console.log(` Converted: ${convertedDuration.minutes}:${convertedDuration.remainingSeconds.toString().padStart(2, '0')} (${convertedDuration.ticks} ticks)`);
|
|
151
|
-
|
|
152
|
-
const durationDiff = Math.abs(originalDuration.seconds - convertedDuration.seconds);
|
|
153
|
-
console.log(` Time difference: ${durationDiff.toFixed(1)} seconds`);
|
|
154
|
-
|
|
155
|
-
// Summary
|
|
156
|
-
console.log('\n' + '='.repeat(80));
|
|
157
|
-
console.log('SUMMARY');
|
|
158
|
-
console.log('='.repeat(80));
|
|
159
|
-
|
|
160
|
-
const tempoOk = Math.abs(convertedTempo / originalTempo - 1) < 0.05; // within 5%
|
|
161
|
-
const timingOk = originalTimes[5] === convertedTimes[5]; // check 6th event
|
|
162
|
-
const durationOk = durationDiff < 10; // within 10 seconds
|
|
163
|
-
|
|
164
|
-
console.log(`\nā Tempo fix implemented: ${tempoOk ? 'ā
PASS' : 'ā FAIL'} (within 5%)`);
|
|
165
|
-
console.log(`ā Timing accuracy: ${timingOk ? 'ā
PASS' : 'ā FAIL'} (exact match)`);
|
|
166
|
-
console.log(`ā Duration accuracy: ${durationOk ? 'ā
PASS' : 'ā FAIL'} (within 10s)`);
|
|
167
|
-
|
|
168
|
-
if (tempoOk && timingOk && durationOk) {
|
|
169
|
-
console.log('\nš ALL TESTS PASSED! Tempo fix is working correctly.');
|
|
170
|
-
} else {
|
|
171
|
-
console.log('\nā ļø Some tests failed. Review the results above.');
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
console.log(`\nā Converted file saved: ${outputKar}`);
|
|
175
|
-
console.log('');
|