@karaplay/file-coder 1.1.0
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/BROWSER_API.md +318 -0
- package/README.md +216 -0
- package/REFACTORING_SUMMARY.txt +250 -0
- package/WORKFLOW_SUMMARY.txt +78 -0
- package/bin/ncntokar-cli.js +39 -0
- package/dist/client.d.ts +26 -0
- package/dist/client.js +74 -0
- package/dist/emk/client-decoder.d.ts +22 -0
- package/dist/emk/client-decoder.js +133 -0
- package/dist/emk/server-decode.d.ts +21 -0
- package/dist/emk/server-decode.js +123 -0
- package/dist/emk-to-kar.browser.d.ts +51 -0
- package/dist/emk-to-kar.browser.js +139 -0
- package/dist/emk-to-kar.d.ts +53 -0
- package/dist/emk-to-kar.js +210 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +64 -0
- package/dist/kar-reader.browser.d.ts +46 -0
- package/dist/kar-reader.browser.js +209 -0
- package/dist/kar-reader.d.ts +42 -0
- package/dist/kar-reader.js +197 -0
- package/dist/ncntokar.browser.d.ts +99 -0
- package/dist/ncntokar.browser.js +296 -0
- package/dist/ncntokar.d.ts +88 -0
- package/dist/ncntokar.js +340 -0
- package/examples/NextJSComponent.tsx +175 -0
- package/libs/emk/client-decoder.ts +142 -0
- package/libs/emk/server-decode.ts +133 -0
- package/libs/ncntokar.js +256 -0
- package/package.json +79 -0
- package/songs/.gitkeep +3 -0
- package/songs/cur/BPL3457.cur +0 -0
- package/songs/cur/Z2510006.cur +0 -0
- package/songs/cur/Z2510008.cur +0 -0
- package/songs/cur/Z2510136.cur +0 -0
- package/songs/cur/Z2510137.cur +0 -0
- package/songs/cur/Z2510138.cur +0 -0
- package/songs/cur/Z2510139.cur +0 -0
- package/songs/cur/Z2510140.cur +0 -0
- package/songs/cur/Z2510141.cur +0 -0
- package/songs/cur/song.cur +0 -0
- package/songs/emk/Z2510001.emk +0 -0
- package/songs/emk/Z2510002.emk +0 -0
- package/songs/emk/Z2510003.emk +0 -0
- package/songs/emk/Z2510004.emk +0 -0
- package/songs/emk/Z2510005.emk +0 -0
- package/songs/emk/Z2510006.emk +0 -0
- package/songs/kar/bpl3457.kar +0 -0
- package/songs/kar/z2510006.kar +0 -0
- package/songs/kar/z2510008.kar +0 -0
- package/songs/kar/z2510136.kar +0 -0
- package/songs/kar/z2510137.kar +0 -0
- package/songs/kar/z2510138.kar +0 -0
- package/songs/kar/z2510139.kar +0 -0
- package/songs/kar/z2510140.kar +0 -0
- package/songs/kar/z2510141.kar +0 -0
- package/songs/lyr/BPL3457.lyr +57 -0
- package/songs/lyr/Z2510006.lyr +53 -0
- package/songs/lyr/Z2510008.lyr +57 -0
- package/songs/lyr/Z2510136.lyr +54 -0
- package/songs/lyr/Z2510137.lyr +66 -0
- package/songs/lyr/Z2510138.lyr +62 -0
- package/songs/lyr/Z2510139.lyr +60 -0
- package/songs/lyr/Z2510140.lyr +41 -0
- package/songs/lyr/Z2510141.lyr +48 -0
- package/songs/lyr/song.lyr +37 -0
- package/songs/midi/BPL3457.MID +0 -0
- package/songs/midi/Z2510006.mid +0 -0
- package/songs/midi/Z2510008.mid +0 -0
- package/songs/midi/Z2510136.mid +0 -0
- package/songs/midi/Z2510137.mid +0 -0
- package/songs/midi/Z2510138.mid +0 -0
- package/songs/midi/Z2510139.mid +0 -0
- package/songs/midi/Z2510140.mid +0 -0
- package/songs/midi/Z2510141.mid +0 -0
package/dist/ncntokar.js
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* NCN (.mid + .lyr + .cur) -> .kar (MIDI with embedded karaoke/lyric/title/artist tracks)
|
|
4
|
+
* - .lyr decoded as TIS-620 (Thai encoding)
|
|
5
|
+
* - meta events use midi-file format: { deltaTime, type: <subtype>, text }
|
|
6
|
+
*/
|
|
7
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
8
|
+
if (k2 === undefined) k2 = k;
|
|
9
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
10
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
11
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
12
|
+
}
|
|
13
|
+
Object.defineProperty(o, k2, desc);
|
|
14
|
+
}) : (function(o, m, k, k2) {
|
|
15
|
+
if (k2 === undefined) k2 = k;
|
|
16
|
+
o[k2] = m[k];
|
|
17
|
+
}));
|
|
18
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
19
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
20
|
+
}) : function(o, v) {
|
|
21
|
+
o["default"] = v;
|
|
22
|
+
});
|
|
23
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
24
|
+
var ownKeys = function(o) {
|
|
25
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
26
|
+
var ar = [];
|
|
27
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
28
|
+
return ar;
|
|
29
|
+
};
|
|
30
|
+
return ownKeys(o);
|
|
31
|
+
};
|
|
32
|
+
return function (mod) {
|
|
33
|
+
if (mod && mod.__esModule) return mod;
|
|
34
|
+
var result = {};
|
|
35
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
36
|
+
__setModuleDefault(result, mod);
|
|
37
|
+
return result;
|
|
38
|
+
};
|
|
39
|
+
})();
|
|
40
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
41
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
42
|
+
};
|
|
43
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
44
|
+
exports.CursorReader = void 0;
|
|
45
|
+
exports.readFileTextTIS620 = readFileTextTIS620;
|
|
46
|
+
exports.splitLinesKeepEndings = splitLinesKeepEndings;
|
|
47
|
+
exports.trimLineEndings = trimLineEndings;
|
|
48
|
+
exports.metaEvent = metaEvent;
|
|
49
|
+
exports.endOfTrack = endOfTrack;
|
|
50
|
+
exports.ensureReadableFile = ensureReadableFile;
|
|
51
|
+
exports.ensureOutputDoesNotExist = ensureOutputDoesNotExist;
|
|
52
|
+
exports.parseLyricFile = parseLyricFile;
|
|
53
|
+
exports.buildKaraokeTrack = buildKaraokeTrack;
|
|
54
|
+
exports.buildMetadataTracks = buildMetadataTracks;
|
|
55
|
+
exports.convertNcnToKar = convertNcnToKar;
|
|
56
|
+
exports.convertWithDefaults = convertWithDefaults;
|
|
57
|
+
const fs = __importStar(require("fs"));
|
|
58
|
+
const path = __importStar(require("path"));
|
|
59
|
+
const iconv = __importStar(require("iconv-lite"));
|
|
60
|
+
const grapheme_splitter_1 = __importDefault(require("grapheme-splitter"));
|
|
61
|
+
const midi_file_1 = require("midi-file");
|
|
62
|
+
class CursorReader {
|
|
63
|
+
constructor(buf) {
|
|
64
|
+
this.buf = buf;
|
|
65
|
+
this.off = 0;
|
|
66
|
+
}
|
|
67
|
+
readU16LE() {
|
|
68
|
+
if (this.off + 2 > this.buf.length)
|
|
69
|
+
return null;
|
|
70
|
+
const v = this.buf.readUInt16LE(this.off);
|
|
71
|
+
this.off += 2;
|
|
72
|
+
return v;
|
|
73
|
+
}
|
|
74
|
+
readU8() {
|
|
75
|
+
if (this.off + 1 > this.buf.length)
|
|
76
|
+
return null;
|
|
77
|
+
const v = this.buf[this.off];
|
|
78
|
+
this.off += 1;
|
|
79
|
+
return v;
|
|
80
|
+
}
|
|
81
|
+
remaining() {
|
|
82
|
+
return this.buf.length - this.off;
|
|
83
|
+
}
|
|
84
|
+
eof() {
|
|
85
|
+
return this.off >= this.buf.length;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
exports.CursorReader = CursorReader;
|
|
89
|
+
/**
|
|
90
|
+
* Reads a file and decodes it as TIS-620 (Thai encoding)
|
|
91
|
+
*/
|
|
92
|
+
function readFileTextTIS620(filePath) {
|
|
93
|
+
const buf = fs.readFileSync(filePath);
|
|
94
|
+
return iconv.decode(buf, "tis-620");
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Splits text into lines while keeping line endings
|
|
98
|
+
*/
|
|
99
|
+
function splitLinesKeepEndings(text) {
|
|
100
|
+
return text.match(/[^\n]*\n|[^\n]+$/g) || [];
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Removes line endings from a string
|
|
104
|
+
*/
|
|
105
|
+
function trimLineEndings(s) {
|
|
106
|
+
return s.replace(/[\r\n]+$/g, "");
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Creates a MIDI meta event with TIS-620 encoding for Thai text
|
|
110
|
+
*/
|
|
111
|
+
function metaEvent(subtype, deltaTime, text) {
|
|
112
|
+
// Encode text back to TIS-620 bytes (original encoding) to preserve Thai characters
|
|
113
|
+
const tis620Bytes = iconv.encode(text, 'tis-620');
|
|
114
|
+
// Map subtype to metatypeByte
|
|
115
|
+
const metaTypeMap = {
|
|
116
|
+
'text': 0x01,
|
|
117
|
+
'trackName': 0x03,
|
|
118
|
+
'copyrightNotice': 0x02,
|
|
119
|
+
'instrumentName': 0x04,
|
|
120
|
+
'lyrics': 0x05,
|
|
121
|
+
'marker': 0x06,
|
|
122
|
+
'cuePoint': 0x07,
|
|
123
|
+
};
|
|
124
|
+
const metatypeByte = metaTypeMap[subtype];
|
|
125
|
+
if (metatypeByte !== undefined) {
|
|
126
|
+
// Use unknownMeta with proper metatypeByte to write TIS-620 bytes correctly
|
|
127
|
+
return {
|
|
128
|
+
deltaTime,
|
|
129
|
+
type: 'unknownMeta',
|
|
130
|
+
metatypeByte: metatypeByte,
|
|
131
|
+
data: Array.from(tis620Bytes)
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
// Fallback to standard format for other types
|
|
135
|
+
return { deltaTime, type: subtype, text };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Creates an end-of-track MIDI event
|
|
139
|
+
*/
|
|
140
|
+
function endOfTrack(deltaTime = 0) {
|
|
141
|
+
return { deltaTime, type: "endOfTrack" };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Validates that a file exists and is readable
|
|
145
|
+
*/
|
|
146
|
+
function ensureReadableFile(filePath) {
|
|
147
|
+
try {
|
|
148
|
+
fs.accessSync(filePath, fs.constants.R_OK);
|
|
149
|
+
if (!fs.statSync(filePath).isFile()) {
|
|
150
|
+
throw new Error(`Not a file: ${filePath}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
throw new Error(`Cannot read file: ${filePath}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Ensures output file doesn't exist and creates directory if needed
|
|
159
|
+
*/
|
|
160
|
+
function ensureOutputDoesNotExist(filePath, createDir = true) {
|
|
161
|
+
if (fs.existsSync(filePath)) {
|
|
162
|
+
throw new Error(`Output already exists: ${filePath}`);
|
|
163
|
+
}
|
|
164
|
+
if (createDir) {
|
|
165
|
+
const dir = path.dirname(filePath);
|
|
166
|
+
if (dir && dir !== "." && !fs.existsSync(dir)) {
|
|
167
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Parses lyric file and extracts metadata (title, artist, lyrics)
|
|
173
|
+
*/
|
|
174
|
+
function parseLyricFile(lyricFilePath) {
|
|
175
|
+
const lyricText = readFileTextTIS620(lyricFilePath);
|
|
176
|
+
const linesWithEndings = splitLinesKeepEndings(lyricText);
|
|
177
|
+
if (linesWithEndings.length < 2) {
|
|
178
|
+
throw new Error("Lyric file has too few lines (need title and artist).");
|
|
179
|
+
}
|
|
180
|
+
const songTitle = trimLineEndings(linesWithEndings[0] ?? "");
|
|
181
|
+
const artistName = trimLineEndings(linesWithEndings[1] ?? "");
|
|
182
|
+
// Discard line 3 & 4; lyrics start from line 5 (index 4)
|
|
183
|
+
let fullLyric = "";
|
|
184
|
+
const lyricLines = [];
|
|
185
|
+
for (let i = 4; i < linesWithEndings.length; i++) {
|
|
186
|
+
fullLyric += linesWithEndings[i];
|
|
187
|
+
const trimmed = trimLineEndings(linesWithEndings[i]);
|
|
188
|
+
if (trimmed && trimmed.length > 0) {
|
|
189
|
+
lyricLines.push(trimmed);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
title: songTitle,
|
|
194
|
+
artist: artistName,
|
|
195
|
+
fullLyric,
|
|
196
|
+
lines: lyricLines
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Builds karaoke track with timing information
|
|
201
|
+
*/
|
|
202
|
+
function buildKaraokeTrack(metadata, cursorBuffer, ticksPerBeat) {
|
|
203
|
+
const karaokeTrack = [];
|
|
204
|
+
const warnings = [];
|
|
205
|
+
karaokeTrack.push(metaEvent("trackName", 0, "Words"));
|
|
206
|
+
karaokeTrack.push(metaEvent("text", 0, "@T" + metadata.title));
|
|
207
|
+
karaokeTrack.push(metaEvent("text", 0, "@T" + metadata.artist));
|
|
208
|
+
const cursor = new CursorReader(cursorBuffer);
|
|
209
|
+
const splitter = new grapheme_splitter_1.default();
|
|
210
|
+
let previousAbsoluteTimestamp = 0;
|
|
211
|
+
// Process each lyric line
|
|
212
|
+
const lyricsWithEndings = splitLinesKeepEndings(metadata.fullLyric);
|
|
213
|
+
for (let i = 4; i < lyricsWithEndings.length; i++) {
|
|
214
|
+
const trimmed = trimLineEndings(lyricsWithEndings[i]);
|
|
215
|
+
if (!trimmed || trimmed.length === 0)
|
|
216
|
+
continue;
|
|
217
|
+
const lineForTiming = "/" + trimmed;
|
|
218
|
+
const graphemes = splitter.splitGraphemes(lineForTiming);
|
|
219
|
+
for (const g of graphemes) {
|
|
220
|
+
let absoluteTimestamp = previousAbsoluteTimestamp;
|
|
221
|
+
// Read 2 bytes per codepoint inside grapheme
|
|
222
|
+
for (const _cp of Array.from(g)) {
|
|
223
|
+
const v = cursor.readU16LE();
|
|
224
|
+
if (v === null) {
|
|
225
|
+
warnings.push("ran out of timing info; reusing previous timestamp");
|
|
226
|
+
absoluteTimestamp = previousAbsoluteTimestamp;
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
absoluteTimestamp = v;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Conversion: * (ticksPerBeat / 24)
|
|
233
|
+
absoluteTimestamp = Math.floor(absoluteTimestamp * (ticksPerBeat / 24));
|
|
234
|
+
if (absoluteTimestamp < previousAbsoluteTimestamp) {
|
|
235
|
+
warnings.push("timestamp out of order - clamping");
|
|
236
|
+
absoluteTimestamp = previousAbsoluteTimestamp;
|
|
237
|
+
}
|
|
238
|
+
const relativeTimestamp = absoluteTimestamp - previousAbsoluteTimestamp;
|
|
239
|
+
karaokeTrack.push(metaEvent("text", relativeTimestamp, g));
|
|
240
|
+
previousAbsoluteTimestamp = absoluteTimestamp;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
// Check terminator
|
|
244
|
+
const terminator = cursor.readU8();
|
|
245
|
+
if (terminator === null) {
|
|
246
|
+
warnings.push("EOF without terminator while reading timing info");
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
const leftover = cursor.remaining();
|
|
250
|
+
if (terminator !== 0xff || leftover > 0) {
|
|
251
|
+
warnings.push(`${leftover} bytes (${leftover / 2} values) of unused timing info, or timing info ended without 0xFF terminator`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
karaokeTrack.push(endOfTrack(0));
|
|
255
|
+
return { track: karaokeTrack, warnings };
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Builds additional metadata tracks (Lyric, Artist, SongTitle)
|
|
259
|
+
*/
|
|
260
|
+
function buildMetadataTracks(metadata) {
|
|
261
|
+
const lyricTrack = [
|
|
262
|
+
metaEvent("trackName", 0, "Lyric"),
|
|
263
|
+
metaEvent("text", 0, metadata.fullLyric),
|
|
264
|
+
endOfTrack(0),
|
|
265
|
+
];
|
|
266
|
+
const artistTrack = [
|
|
267
|
+
metaEvent("trackName", 0, "Artist"),
|
|
268
|
+
metaEvent("text", 0, metadata.artist),
|
|
269
|
+
endOfTrack(0),
|
|
270
|
+
];
|
|
271
|
+
const titleTrack = [
|
|
272
|
+
metaEvent("trackName", 0, "SongTitle"),
|
|
273
|
+
metaEvent("text", 0, metadata.title),
|
|
274
|
+
endOfTrack(0),
|
|
275
|
+
];
|
|
276
|
+
return { lyricTrack, artistTrack, titleTrack };
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Main conversion function: converts NCN files (.mid + .lyr + .cur) to .kar format
|
|
280
|
+
*/
|
|
281
|
+
function convertNcnToKar(options) {
|
|
282
|
+
const warnings = [];
|
|
283
|
+
// Validate input files
|
|
284
|
+
ensureReadableFile(options.inputMidi);
|
|
285
|
+
ensureReadableFile(options.inputLyr);
|
|
286
|
+
ensureReadableFile(options.inputCur);
|
|
287
|
+
ensureOutputDoesNotExist(options.outputKar);
|
|
288
|
+
// Parse MIDI
|
|
289
|
+
const midi = (0, midi_file_1.parseMidi)(fs.readFileSync(options.inputMidi));
|
|
290
|
+
const ticksPerBeat = midi.header && midi.header.ticksPerBeat;
|
|
291
|
+
if (!ticksPerBeat) {
|
|
292
|
+
throw new Error("Only ticks-per-beat MIDI timing is supported (SMPTE timing not supported).");
|
|
293
|
+
}
|
|
294
|
+
// Parse lyric file
|
|
295
|
+
const metadata = parseLyricFile(options.inputLyr);
|
|
296
|
+
// Build karaoke track
|
|
297
|
+
const cursorBuffer = fs.readFileSync(options.inputCur);
|
|
298
|
+
const { track: karaokeTrack, warnings: karaokeWarnings } = buildKaraokeTrack(metadata, cursorBuffer, ticksPerBeat);
|
|
299
|
+
warnings.push(...karaokeWarnings);
|
|
300
|
+
// Build metadata tracks
|
|
301
|
+
const { lyricTrack, artistTrack, titleTrack } = buildMetadataTracks(metadata);
|
|
302
|
+
// Insert tracks as second track (index 1)
|
|
303
|
+
const originalTracks = midi.tracks || [];
|
|
304
|
+
const newTracks = originalTracks.slice();
|
|
305
|
+
newTracks.splice(1, 0, karaokeTrack, lyricTrack, artistTrack, titleTrack);
|
|
306
|
+
midi.tracks = newTracks;
|
|
307
|
+
// Write output
|
|
308
|
+
const outBytes = (0, midi_file_1.writeMidi)(midi);
|
|
309
|
+
fs.writeFileSync(options.outputKar, Buffer.from(outBytes));
|
|
310
|
+
// Optionally append to titles.txt
|
|
311
|
+
if (options.appendTitles) {
|
|
312
|
+
const titlesFile = options.titlesFile || path.join(path.dirname(options.outputKar), "titles.txt");
|
|
313
|
+
fs.appendFileSync(titlesFile, `${path.basename(options.outputKar)}\t${metadata.title}\t${metadata.artist}\n`, "utf8");
|
|
314
|
+
}
|
|
315
|
+
return {
|
|
316
|
+
success: true,
|
|
317
|
+
outputFile: options.outputKar,
|
|
318
|
+
warnings,
|
|
319
|
+
metadata
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* CLI-compatible function that uses hard-coded paths (for backward compatibility)
|
|
324
|
+
*/
|
|
325
|
+
function convertWithDefaults(inputMidi, inputLyr, inputCur, outputKar) {
|
|
326
|
+
const result = convertNcnToKar({
|
|
327
|
+
inputMidi,
|
|
328
|
+
inputLyr,
|
|
329
|
+
inputCur,
|
|
330
|
+
outputKar,
|
|
331
|
+
appendTitles: true
|
|
332
|
+
});
|
|
333
|
+
if (result.warnings.length > 0) {
|
|
334
|
+
result.warnings.forEach(w => console.warn(`[warn] ${w}`));
|
|
335
|
+
}
|
|
336
|
+
console.log("✅ Done");
|
|
337
|
+
console.log(`- Output: ${result.outputFile}`);
|
|
338
|
+
console.log(`- Title: ${result.metadata.title}`);
|
|
339
|
+
console.log(`- Artist: ${result.metadata.artist}`);
|
|
340
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Example Next.js Client Component for EMK to KAR conversion
|
|
3
|
+
* This demonstrates how to use file-coder in a Next.js app
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import { useState } from 'react';
|
|
9
|
+
import {
|
|
10
|
+
convertEmkFileToKar,
|
|
11
|
+
convertEmkFilesBatch,
|
|
12
|
+
type BrowserEmkToKarResult
|
|
13
|
+
} from 'file-coder/client';
|
|
14
|
+
|
|
15
|
+
export default function EmkConverter() {
|
|
16
|
+
const [converting, setConverting] = useState(false);
|
|
17
|
+
const [result, setResult] = useState<BrowserEmkToKarResult | null>(null);
|
|
18
|
+
const [error, setError] = useState<string | null>(null);
|
|
19
|
+
|
|
20
|
+
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
21
|
+
const file = e.target.files?.[0];
|
|
22
|
+
if (!file) return;
|
|
23
|
+
|
|
24
|
+
setConverting(true);
|
|
25
|
+
setError(null);
|
|
26
|
+
setResult(null);
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
// Convert and auto-download the KAR file
|
|
30
|
+
const conversionResult = await convertEmkFileToKar(file, {
|
|
31
|
+
autoDownload: true
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
setResult(conversionResult);
|
|
35
|
+
console.log('✓ Converted:', conversionResult.metadata.title);
|
|
36
|
+
console.log('✓ Artist:', conversionResult.metadata.artist);
|
|
37
|
+
|
|
38
|
+
if (conversionResult.warnings.length > 0) {
|
|
39
|
+
console.warn('Warnings:', conversionResult.warnings);
|
|
40
|
+
}
|
|
41
|
+
} catch (err: any) {
|
|
42
|
+
setError(err.message);
|
|
43
|
+
console.error('Conversion failed:', err);
|
|
44
|
+
} finally {
|
|
45
|
+
setConverting(false);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const handleBatchUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
50
|
+
const files = Array.from(e.target.files || []);
|
|
51
|
+
if (files.length === 0) return;
|
|
52
|
+
|
|
53
|
+
setConverting(true);
|
|
54
|
+
setError(null);
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
// Convert multiple files
|
|
58
|
+
const results = await convertEmkFilesBatch(files, {
|
|
59
|
+
autoDownload: true
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const successCount = results.filter(r => r.success).length;
|
|
63
|
+
console.log(`✓ Converted ${successCount}/${files.length} files`);
|
|
64
|
+
|
|
65
|
+
results.forEach(r => {
|
|
66
|
+
if (r.success) {
|
|
67
|
+
console.log(` ✓ ${r.fileName}: ${r.metadata.title}`);
|
|
68
|
+
} else {
|
|
69
|
+
console.error(` ✗ ${r.fileName}: ${r.warnings.join(', ')}`);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
} catch (err: any) {
|
|
73
|
+
setError(err.message);
|
|
74
|
+
console.error('Batch conversion failed:', err);
|
|
75
|
+
} finally {
|
|
76
|
+
setConverting(false);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="max-w-2xl mx-auto p-6">
|
|
82
|
+
<h1 className="text-3xl font-bold mb-6">EMK to KAR Converter</h1>
|
|
83
|
+
|
|
84
|
+
{/* Single file upload */}
|
|
85
|
+
<div className="mb-8">
|
|
86
|
+
<h2 className="text-xl font-semibold mb-3">Convert Single File</h2>
|
|
87
|
+
<input
|
|
88
|
+
type="file"
|
|
89
|
+
accept=".emk"
|
|
90
|
+
onChange={handleFileUpload}
|
|
91
|
+
disabled={converting}
|
|
92
|
+
className="block w-full text-sm text-gray-500
|
|
93
|
+
file:mr-4 file:py-2 file:px-4
|
|
94
|
+
file:rounded-full file:border-0
|
|
95
|
+
file:text-sm file:font-semibold
|
|
96
|
+
file:bg-blue-50 file:text-blue-700
|
|
97
|
+
hover:file:bg-blue-100
|
|
98
|
+
disabled:opacity-50"
|
|
99
|
+
/>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
{/* Batch upload */}
|
|
103
|
+
<div className="mb-8">
|
|
104
|
+
<h2 className="text-xl font-semibold mb-3">Convert Multiple Files</h2>
|
|
105
|
+
<input
|
|
106
|
+
type="file"
|
|
107
|
+
accept=".emk"
|
|
108
|
+
multiple
|
|
109
|
+
onChange={handleBatchUpload}
|
|
110
|
+
disabled={converting}
|
|
111
|
+
className="block w-full text-sm text-gray-500
|
|
112
|
+
file:mr-4 file:py-2 file:px-4
|
|
113
|
+
file:rounded-full file:border-0
|
|
114
|
+
file:text-sm file:font-semibold
|
|
115
|
+
file:bg-green-50 file:text-green-700
|
|
116
|
+
hover:file:bg-green-100
|
|
117
|
+
disabled:opacity-50"
|
|
118
|
+
/>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{/* Status */}
|
|
122
|
+
{converting && (
|
|
123
|
+
<div className="bg-blue-50 border border-blue-200 rounded p-4 mb-4">
|
|
124
|
+
<p className="text-blue-800">Converting... Please wait.</p>
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
{error && (
|
|
129
|
+
<div className="bg-red-50 border border-red-200 rounded p-4 mb-4">
|
|
130
|
+
<p className="text-red-800 font-semibold">Error:</p>
|
|
131
|
+
<p className="text-red-700">{error}</p>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
{result && result.success && (
|
|
136
|
+
<div className="bg-green-50 border border-green-200 rounded p-4">
|
|
137
|
+
<p className="text-green-800 font-semibold mb-2">✓ Conversion Successful!</p>
|
|
138
|
+
<div className="text-sm text-green-700">
|
|
139
|
+
<p><strong>Title:</strong> {result.metadata.title}</p>
|
|
140
|
+
<p><strong>Artist:</strong> {result.metadata.artist}</p>
|
|
141
|
+
<p><strong>File:</strong> {result.fileName}</p>
|
|
142
|
+
{result.metadata.code && (
|
|
143
|
+
<p><strong>Code:</strong> {result.metadata.code}</p>
|
|
144
|
+
)}
|
|
145
|
+
{result.warnings.length > 0 && (
|
|
146
|
+
<div className="mt-2">
|
|
147
|
+
<p className="font-semibold">Warnings:</p>
|
|
148
|
+
<ul className="list-disc list-inside">
|
|
149
|
+
{result.warnings.map((w, i) => (
|
|
150
|
+
<li key={i}>{w}</li>
|
|
151
|
+
))}
|
|
152
|
+
</ul>
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
{/* Info */}
|
|
160
|
+
<div className="mt-8 p-4 bg-gray-50 rounded">
|
|
161
|
+
<h3 className="font-semibold mb-2">About</h3>
|
|
162
|
+
<p className="text-sm text-gray-600">
|
|
163
|
+
This converter processes .emk (Extreme Karaoke) files and converts them
|
|
164
|
+
to .kar (MIDI Karaoke) format. The converted files will automatically
|
|
165
|
+
download to your device.
|
|
166
|
+
</p>
|
|
167
|
+
<p className="text-sm text-gray-600 mt-2">
|
|
168
|
+
Supports Thai lyrics (TIS-620 encoding) and maintains proper timing
|
|
169
|
+
information for karaoke display.
|
|
170
|
+
</p>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* @fileoverview Main decoding logic for .emk (Extreme Karaoke) files.
|
|
4
|
+
* This file is now intended for CLIENT-SIDE use.
|
|
5
|
+
*/
|
|
6
|
+
'use client';
|
|
7
|
+
|
|
8
|
+
import { inflate } from 'pako';
|
|
9
|
+
|
|
10
|
+
const XOR_KEY = Buffer.from([0xAF, 0xF2, 0x4C, 0x9C, 0xE9, 0xEA, 0x99, 0x43]);
|
|
11
|
+
const MAGIC_SIGNATURE = '.SFDS';
|
|
12
|
+
|
|
13
|
+
export interface DecodedEmkParts {
|
|
14
|
+
header?: Buffer;
|
|
15
|
+
songInfo?: Buffer;
|
|
16
|
+
midi?: Buffer;
|
|
17
|
+
lyric?: Buffer;
|
|
18
|
+
cursor?: Buffer;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function xorDecrypt(data: Buffer): Buffer {
|
|
22
|
+
const decrypted = Buffer.alloc(data.length);
|
|
23
|
+
for (let i = 0; i < data.length; i++) {
|
|
24
|
+
decrypted[i] = data[i] ^ XOR_KEY[i % XOR_KEY.length];
|
|
25
|
+
}
|
|
26
|
+
return decrypted;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function looksLikeText(buf: Buffer): boolean {
|
|
30
|
+
const sample = buf.subarray(0, Math.min(64, buf.length));
|
|
31
|
+
for (let i = 0; i < sample.length; i++) {
|
|
32
|
+
const c = sample[i];
|
|
33
|
+
// Check for common text characters (ASCII, line endings, Thai)
|
|
34
|
+
if (c === 0x0a || c === 0x0d || c === 0x09 || (c >= 0x20 && c <= 0x7e) || c >= 0x80) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Decodes an .emk file buffer into its constituent parts on the client-side.
|
|
44
|
+
* This uses `pako` for zlib decompression in the browser.
|
|
45
|
+
* @param fileBuffer The raw Buffer from the .emk file.
|
|
46
|
+
*/
|
|
47
|
+
export function decodeEmk(fileBuffer: Buffer): DecodedEmkParts {
|
|
48
|
+
const decryptedBuffer = xorDecrypt(fileBuffer);
|
|
49
|
+
|
|
50
|
+
const magic = decryptedBuffer.subarray(0, MAGIC_SIGNATURE.length).toString('utf-8');
|
|
51
|
+
if (magic !== MAGIC_SIGNATURE) {
|
|
52
|
+
throw new Error(`Invalid EMK file signature. Expected '${MAGIC_SIGNATURE}' but got '${magic}'.`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const result: DecodedEmkParts = {};
|
|
56
|
+
const inflatedParts: Buffer[] = [];
|
|
57
|
+
|
|
58
|
+
for (let i = 0; i < decryptedBuffer.length - 2; i++) {
|
|
59
|
+
const b0 = decryptedBuffer[i];
|
|
60
|
+
|
|
61
|
+
// Zlib streams start with 0x78
|
|
62
|
+
if (b0 !== 0x78) continue;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
// Attempt to inflate from this point to the end of the buffer
|
|
66
|
+
const inflatedUint8 = inflate(decryptedBuffer.subarray(i));
|
|
67
|
+
const inflated = Buffer.from(inflatedUint8);
|
|
68
|
+
inflatedParts.push(inflated);
|
|
69
|
+
// This is a naive scan; a successful inflation doesn't mean we can skip.
|
|
70
|
+
// The original data is a series of concatenated zlib streams. We must find them all.
|
|
71
|
+
} catch (e: any) {
|
|
72
|
+
// This is expected for many positions, as not every 0x78 starts a valid stream.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (inflatedParts.length < 3) {
|
|
77
|
+
throw new Error(`Invalid EMK structure: expected at least 3 zlib blocks, found ${inflatedParts.length}.`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const inflated of inflatedParts) {
|
|
81
|
+
const asciiPrefix = inflated.subarray(0, 16).toString('ascii');
|
|
82
|
+
|
|
83
|
+
if (asciiPrefix.startsWith('SIGNATURE=')) {
|
|
84
|
+
result.header = inflated;
|
|
85
|
+
} else if (asciiPrefix.startsWith('CODE=')) {
|
|
86
|
+
result.songInfo = inflated;
|
|
87
|
+
} else if (inflated.subarray(0, 4).toString('ascii') === 'MThd') {
|
|
88
|
+
result.midi = inflated;
|
|
89
|
+
} else if (looksLikeText(inflated)) {
|
|
90
|
+
if (!result.lyric) {
|
|
91
|
+
result.lyric = inflated;
|
|
92
|
+
} else {
|
|
93
|
+
result.cursor = inflated;
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
if (!result.cursor) {
|
|
97
|
+
result.cursor = inflated;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!result.midi) throw new Error('MIDI data block not found in EMK file.');
|
|
103
|
+
if (!result.lyric) throw new Error('Lyric data block not found in EMK file.');
|
|
104
|
+
if (!result.cursor) throw new Error('Cursor data block not found in EMK file.');
|
|
105
|
+
|
|
106
|
+
if (!result.songInfo) {
|
|
107
|
+
result.songInfo = Buffer.from('CODE=\nTITLE=Unknown Title\nARTIST=Unknown Artist');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Parses the "song info" block from a Buffer.
|
|
116
|
+
* This is safe to run on the client as it's pure JS.
|
|
117
|
+
* @param songInfoBuffer The buffer containing the song info data.
|
|
118
|
+
* @returns A record containing TITLE, ARTIST, and CODE.
|
|
119
|
+
*/
|
|
120
|
+
export function parseSongInfo(songInfoBuffer: Buffer | undefined): Record<string, string> {
|
|
121
|
+
if (!songInfoBuffer) return {};
|
|
122
|
+
try {
|
|
123
|
+
// Use TextDecoder, which is available in modern browsers.
|
|
124
|
+
const text = new TextDecoder('windows-874', { fatal: false }).decode(songInfoBuffer);
|
|
125
|
+
const lines = text.split(/\r?\n/);
|
|
126
|
+
const info: Record<string, string> = {};
|
|
127
|
+
for (const line of lines) {
|
|
128
|
+
const parts = line.split('=');
|
|
129
|
+
if (parts.length >= 2) {
|
|
130
|
+
const key = parts[0].trim().toUpperCase();
|
|
131
|
+
const value = parts.slice(1).join('=').trim();
|
|
132
|
+
if (key === 'TITLE' || key === 'ARTIST' || key === 'CODE') {
|
|
133
|
+
info[key] = value;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return info;
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.error("Failed to parse song info", e);
|
|
140
|
+
return {};
|
|
141
|
+
}
|
|
142
|
+
}
|