@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.
Files changed (75) hide show
  1. package/BROWSER_API.md +318 -0
  2. package/README.md +216 -0
  3. package/REFACTORING_SUMMARY.txt +250 -0
  4. package/WORKFLOW_SUMMARY.txt +78 -0
  5. package/bin/ncntokar-cli.js +39 -0
  6. package/dist/client.d.ts +26 -0
  7. package/dist/client.js +74 -0
  8. package/dist/emk/client-decoder.d.ts +22 -0
  9. package/dist/emk/client-decoder.js +133 -0
  10. package/dist/emk/server-decode.d.ts +21 -0
  11. package/dist/emk/server-decode.js +123 -0
  12. package/dist/emk-to-kar.browser.d.ts +51 -0
  13. package/dist/emk-to-kar.browser.js +139 -0
  14. package/dist/emk-to-kar.d.ts +53 -0
  15. package/dist/emk-to-kar.js +210 -0
  16. package/dist/index.d.ts +12 -0
  17. package/dist/index.js +64 -0
  18. package/dist/kar-reader.browser.d.ts +46 -0
  19. package/dist/kar-reader.browser.js +209 -0
  20. package/dist/kar-reader.d.ts +42 -0
  21. package/dist/kar-reader.js +197 -0
  22. package/dist/ncntokar.browser.d.ts +99 -0
  23. package/dist/ncntokar.browser.js +296 -0
  24. package/dist/ncntokar.d.ts +88 -0
  25. package/dist/ncntokar.js +340 -0
  26. package/examples/NextJSComponent.tsx +175 -0
  27. package/libs/emk/client-decoder.ts +142 -0
  28. package/libs/emk/server-decode.ts +133 -0
  29. package/libs/ncntokar.js +256 -0
  30. package/package.json +79 -0
  31. package/songs/.gitkeep +3 -0
  32. package/songs/cur/BPL3457.cur +0 -0
  33. package/songs/cur/Z2510006.cur +0 -0
  34. package/songs/cur/Z2510008.cur +0 -0
  35. package/songs/cur/Z2510136.cur +0 -0
  36. package/songs/cur/Z2510137.cur +0 -0
  37. package/songs/cur/Z2510138.cur +0 -0
  38. package/songs/cur/Z2510139.cur +0 -0
  39. package/songs/cur/Z2510140.cur +0 -0
  40. package/songs/cur/Z2510141.cur +0 -0
  41. package/songs/cur/song.cur +0 -0
  42. package/songs/emk/Z2510001.emk +0 -0
  43. package/songs/emk/Z2510002.emk +0 -0
  44. package/songs/emk/Z2510003.emk +0 -0
  45. package/songs/emk/Z2510004.emk +0 -0
  46. package/songs/emk/Z2510005.emk +0 -0
  47. package/songs/emk/Z2510006.emk +0 -0
  48. package/songs/kar/bpl3457.kar +0 -0
  49. package/songs/kar/z2510006.kar +0 -0
  50. package/songs/kar/z2510008.kar +0 -0
  51. package/songs/kar/z2510136.kar +0 -0
  52. package/songs/kar/z2510137.kar +0 -0
  53. package/songs/kar/z2510138.kar +0 -0
  54. package/songs/kar/z2510139.kar +0 -0
  55. package/songs/kar/z2510140.kar +0 -0
  56. package/songs/kar/z2510141.kar +0 -0
  57. package/songs/lyr/BPL3457.lyr +57 -0
  58. package/songs/lyr/Z2510006.lyr +53 -0
  59. package/songs/lyr/Z2510008.lyr +57 -0
  60. package/songs/lyr/Z2510136.lyr +54 -0
  61. package/songs/lyr/Z2510137.lyr +66 -0
  62. package/songs/lyr/Z2510138.lyr +62 -0
  63. package/songs/lyr/Z2510139.lyr +60 -0
  64. package/songs/lyr/Z2510140.lyr +41 -0
  65. package/songs/lyr/Z2510141.lyr +48 -0
  66. package/songs/lyr/song.lyr +37 -0
  67. package/songs/midi/BPL3457.MID +0 -0
  68. package/songs/midi/Z2510006.mid +0 -0
  69. package/songs/midi/Z2510008.mid +0 -0
  70. package/songs/midi/Z2510136.mid +0 -0
  71. package/songs/midi/Z2510137.mid +0 -0
  72. package/songs/midi/Z2510138.mid +0 -0
  73. package/songs/midi/Z2510139.mid +0 -0
  74. package/songs/midi/Z2510140.mid +0 -0
  75. package/songs/midi/Z2510141.mid +0 -0
@@ -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
+ }