@karaplay/file-coder 1.4.6 → 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.
@@ -0,0 +1,97 @@
1
+ # Release v1.4.7 - ZXIO Format Support
2
+
3
+ ## ✨ New Feature: Custom "zxio" MIDI Format Support
4
+
5
+ ### Problem
6
+ Some EMK files use a custom "zxio" MIDI format instead of the standard "MThd" format. These files failed to convert with the error:
7
+
8
+ ```
9
+ MIDI data block not found in EMK file
10
+ ```
11
+
12
+ ### Solution
13
+ Added detection and conversion support for the "zxio" MIDI format, which appears to be a variant used by certain karaoke systems.
14
+
15
+ #### ZXIO Format Structure
16
+ ```
17
+ - 4 bytes: "zxio" magic signature
18
+ - 4 bytes: header length (big-endian)
19
+ - 2 bytes: format (big-endian)
20
+ - 2 bytes: number of tracks (big-endian)
21
+ - 2 bytes: ticks per beat (big-endian)
22
+ - Followed by MIDI track data (MTrk chunks)
23
+ ```
24
+
25
+ The decoder now automatically:
26
+ 1. Detects "zxio" format blocks
27
+ 2. Extracts MIDI parameters from the zxio header
28
+ 3. Reconstructs a standard MIDI file with proper "MThd" header
29
+ 4. Applies automatic tempo adjustment for EMK timing compatibility
30
+
31
+ ### Testing Results
32
+ Tested with 10 EMK files:
33
+ - ✅ **8 successful** (including previously failing files)
34
+ - ❌ 2 failed (different issues, not zxio-related)
35
+
36
+ **Previously failing files now working:**
37
+ - `failed01.emk` - คนกระจอก by บุ๊ค ศุภกาญจน์ (256 BPM) ✅
38
+ - `001.emk` - คนกระจอก by บุ๊ค ศุภกาญจน์ (256 BPM) ✅
39
+
40
+ ### Changes
41
+
42
+ #### Server-Side (Node.js)
43
+ - ✅ Added `convertZxioToMidi()` function in `src/emk/server-decode.ts`
44
+ - ✅ Added zxio format detection in EMK decoder
45
+ - ✅ Automatic tempo fix applied to converted files
46
+
47
+ #### Browser-Side
48
+ - ✅ Added `convertZxioToMidi()` function in `src/emk/client-decoder.ts`
49
+ - ✅ Full parity with server-side implementation
50
+
51
+ ### Usage
52
+
53
+ **No code changes needed!** The library automatically detects and handles zxio format:
54
+
55
+ ```typescript
56
+ import { convertEmkToKar } from '@karaplay/file-coder';
57
+
58
+ // Works with both standard MThd and custom zxio formats
59
+ const result = convertEmkToKar({
60
+ inputEmk: 'song.emk',
61
+ outputKar: 'song.kar'
62
+ });
63
+ // Automatically detects format and converts ✨
64
+ ```
65
+
66
+ ### Compatibility
67
+ - ✅ Backward compatible with standard MThd format
68
+ - ✅ Tempo fix automatically applied (v1.4.5 feature)
69
+ - ✅ Works in both Node.js and browser environments
70
+ - ✅ No breaking changes
71
+
72
+ ### Technical Details
73
+
74
+ **Ticks Per Beat Adjustment:**
75
+ Some zxio files store ticks per beat as `0x78` (120 decimal), which is adjusted to the standard `96` for proper playback timing.
76
+
77
+ **Tempo Fix:**
78
+ All EMK-sourced MIDI files (including zxio format) automatically receive tempo adjustment:
79
+ - Formula: `adjusted_tempo = original_tempo × (ticksPerBeat / 24)`
80
+ - Example: 64 BPM × 4 = 256 BPM
81
+
82
+ ### Files Modified
83
+ - ✅ `src/emk/server-decode.ts` - Server-side zxio support
84
+ - ✅ `src/emk/client-decoder.ts` - Browser-side zxio support
85
+ - ✅ `package.json` - Version bump to 1.4.7
86
+
87
+ ### Migration
88
+ ✅ **Zero migration required** - Transparent enhancement
89
+
90
+ ---
91
+
92
+ **Full Changelog:** v1.4.6...v1.4.7
93
+
94
+ **Install:**
95
+ ```bash
96
+ npm install @karaplay/file-coder@1.4.7
97
+ ```
@@ -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('');
@@ -4,6 +4,7 @@ export interface DecodedEmkParts {
4
4
  midi?: Buffer;
5
5
  lyric?: Buffer;
6
6
  cursor?: Buffer;
7
+ isZxioFormat?: boolean;
7
8
  }
8
9
  export declare function xorDecrypt(data: Buffer): Buffer;
9
10
  export declare function looksLikeText(buf: Buffer): boolean;
@@ -33,6 +33,31 @@ function looksLikeText(buf) {
33
33
  }
34
34
  return true;
35
35
  }
36
+ /**
37
+ * Convert custom "zxio" MIDI format to standard MIDI format
38
+ * Same as server version but works in browser environment
39
+ */
40
+ function convertZxioToMidi(zxioData) {
41
+ const magic = zxioData.subarray(0, 4).toString('ascii');
42
+ if (magic !== 'zxio') {
43
+ throw new Error(`Expected 'zxio' magic, got '${magic}'`);
44
+ }
45
+ const headerLength = zxioData.readUInt32BE(4);
46
+ const format = zxioData.readUInt16BE(8);
47
+ const numTracks = zxioData.readUInt16BE(10);
48
+ const ticksPerBeat = zxioData.readUInt16BE(12);
49
+ const adjustedTicksPerBeat = ticksPerBeat === 0x78 ? 96 : ticksPerBeat;
50
+ const midiHeader = Buffer.alloc(14);
51
+ midiHeader.write('MThd', 0, 'ascii');
52
+ midiHeader.writeUInt32BE(6, 4);
53
+ midiHeader.writeUInt16BE(format, 8);
54
+ midiHeader.writeUInt16BE(numTracks, 10);
55
+ midiHeader.writeUInt16BE(adjustedTicksPerBeat, 12);
56
+ const trackDataStart = 4 + 4 + headerLength;
57
+ const trackData = zxioData.subarray(trackDataStart);
58
+ const midiData = Buffer.concat([midiHeader, trackData]);
59
+ return midiData;
60
+ }
36
61
  /**
37
62
  * Decodes an .emk file buffer into its constituent parts on the client-side.
38
63
  * This uses `pako` for zlib decompression in the browser.
@@ -87,6 +112,11 @@ function decodeEmk(fileBuffer) {
87
112
  else if (inflated.subarray(0, 4).toString('ascii') === 'MThd') {
88
113
  result.midi = inflated;
89
114
  }
115
+ else if (inflated.subarray(0, 4).toString('ascii') === 'zxio') {
116
+ // Handle custom "zxio" MIDI format
117
+ result.midi = convertZxioToMidi(inflated);
118
+ result.isZxioFormat = true; // Mark as zxio format for special handling
119
+ }
90
120
  else if (looksLikeText(inflated)) {
91
121
  if (!result.lyric) {
92
122
  result.lyric = inflated;
@@ -9,6 +9,7 @@ export interface DecodedEmkParts {
9
9
  midi?: Buffer;
10
10
  lyric?: Buffer;
11
11
  cursor?: Buffer;
12
+ isZxioFormat?: boolean;
12
13
  }
13
14
  export declare function xorDecrypt(data: Buffer): Buffer;
14
15
  export declare function looksLikeText(buf: Buffer): boolean;
@@ -31,6 +31,49 @@ function looksLikeText(buf) {
31
31
  }
32
32
  return true;
33
33
  }
34
+ /**
35
+ * Convert custom "zxio" MIDI format to standard MIDI format
36
+ *
37
+ * The "zxio" format appears to be:
38
+ * - 4 bytes: "zxio" magic
39
+ * - 4 bytes: header length (big-endian)
40
+ * - 2 bytes: format (big-endian, usually 1)
41
+ * - 2 bytes: number of tracks (big-endian)
42
+ * - 2 bytes: ticks per beat (big-endian, note: might be stored with different encoding)
43
+ * - Followed by MIDI track data (MTrk chunks)
44
+ *
45
+ * This function reconstructs a standard MIDI file with proper MThd header
46
+ */
47
+ function convertZxioToMidi(zxioData) {
48
+ // Read zxio header
49
+ const magic = zxioData.subarray(0, 4).toString('ascii'); // "zxio"
50
+ if (magic !== 'zxio') {
51
+ throw new Error(`Expected 'zxio' magic, got '${magic}'`);
52
+ }
53
+ // Parse header info
54
+ const headerLength = zxioData.readUInt32BE(4); // Usually 6
55
+ const format = zxioData.readUInt16BE(8); // MIDI format (0, 1, or 2)
56
+ const numTracks = zxioData.readUInt16BE(10); // Number of tracks
57
+ const ticksPerBeat = zxioData.readUInt16BE(12); // Ticks per beat (might need adjustment)
58
+ // The actual ticks value might be encoded differently in zxio format
59
+ // Common pattern: stored as 0x78 (120) but should be 96 or similar
60
+ // For now, use as-is but could be adjusted based on testing
61
+ const adjustedTicksPerBeat = ticksPerBeat === 0x78 ? 96 : ticksPerBeat;
62
+ // Create standard MIDI header (MThd)
63
+ const midiHeader = Buffer.alloc(14);
64
+ midiHeader.write('MThd', 0, 'ascii'); // Magic
65
+ midiHeader.writeUInt32BE(6, 4); // Header length (always 6)
66
+ midiHeader.writeUInt16BE(format, 8); // Format
67
+ midiHeader.writeUInt16BE(numTracks, 10); // Number of tracks
68
+ midiHeader.writeUInt16BE(adjustedTicksPerBeat, 12); // Ticks per beat
69
+ // The track data starts after the zxio header
70
+ // Header is: magic (4) + length field (4) + header data (headerLength)
71
+ const trackDataStart = 4 + 4 + headerLength;
72
+ const trackData = zxioData.subarray(trackDataStart);
73
+ // Combine MThd header with track data
74
+ const midiData = Buffer.concat([midiHeader, trackData]);
75
+ return midiData;
76
+ }
34
77
  function decodeEmk(fileBuffer) {
35
78
  const decryptedBuffer = xorDecrypt(fileBuffer);
36
79
  const magic = decryptedBuffer.subarray(0, MAGIC_SIGNATURE.length).toString('utf-8');
@@ -66,6 +109,12 @@ function decodeEmk(fileBuffer) {
66
109
  else if (inflated.subarray(0, 4).toString('ascii') === 'MThd') {
67
110
  result.midi = inflated;
68
111
  }
112
+ else if (inflated.subarray(0, 4).toString('ascii') === 'zxio') {
113
+ // Handle custom "zxio" MIDI format (found in some EMK files)
114
+ // This format has a custom header followed by MIDI tracks
115
+ result.midi = convertZxioToMidi(inflated);
116
+ result.isZxioFormat = true; // Mark as zxio format for special handling
117
+ }
69
118
  else if (looksLikeText(inflated)) {
70
119
  if (!result.lyric) {
71
120
  result.lyric = inflated;
@@ -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: true // Always fix tempo for EMK-sourced files
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`);
@@ -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: true // Always fix tempo for EMK-sourced files
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
  };
@@ -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
- absoluteTimestamp = Math.floor(absoluteTimestamp * (ticksPerBeat / 24));
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);
@@ -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
- absoluteTimestamp = Math.floor(absoluteTimestamp * (ticksPerBeat / 24));
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.6",
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",
Binary file
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('');