carta1 1.0.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/bin/cli.js ADDED
@@ -0,0 +1,679 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Carta1 Audio Codec - Command Line Interface
5
+ *
6
+ * Command-line tool for encoding and decoding ATRAC1 audio files.
7
+ * Supports WAV to AEA encoding and AEA to WAV decoding with
8
+ * real-time progress tracking and streaming processing.
9
+ *
10
+ * Usage:
11
+ * atrac1 --encode input.wav output.aea
12
+ * atrac1 --decode input.aea output.wav
13
+ */
14
+
15
+ import { program } from 'commander'
16
+ import fs from 'fs'
17
+ import path from 'path'
18
+ import { performance } from 'perf_hooks'
19
+ import cliProgress from 'cli-progress'
20
+ import wav from 'wav'
21
+
22
+ import { AudioProcessor } from '../codec/io/processor.js'
23
+ import { AeaReader } from '../codec/io/readers.js'
24
+ import { deserializeFrame, AeaFile } from '../codec/io/serialization.js'
25
+ import {
26
+ SAMPLE_RATE,
27
+ SAMPLES_PER_FRAME,
28
+ BITRATE_PER_CHANNEL,
29
+ AEA_TITLE_SIZE,
30
+ AEA_HEADER_SIZE,
31
+ SOUND_UNIT_SIZE,
32
+ } from '../codec/core/constants.js'
33
+
34
+ /**
35
+ * Format duration in seconds to MM:SS format
36
+ *
37
+ * @param {number} seconds - Duration in seconds
38
+ * @returns {string} Formatted time string (MM:SS)
39
+ */
40
+ function formatTime(seconds) {
41
+ const minutes = Math.floor(seconds / 60)
42
+ const secs = Math.floor(seconds % 60)
43
+ return `${minutes.toString().padStart(2, '0')}:${secs
44
+ .toString()
45
+ .padStart(2, '0')}`
46
+ }
47
+
48
+ /**
49
+ * Validate AEA title for length and ASCII encoding
50
+ *
51
+ * @param {string} title - Title to validate
52
+ * @returns {Object} Validation result
53
+ * @returns {boolean} returns.valid - Whether title is valid
54
+ * @returns {string} [returns.error] - Error message if invalid
55
+ */
56
+ function validateTitle(title) {
57
+ if (!title) {
58
+ return { valid: true }
59
+ }
60
+
61
+ // Check ASCII encoding
62
+ for (let i = 0; i < title.length; i++) {
63
+ const charCode = title.charCodeAt(i)
64
+ if (charCode > 127) {
65
+ return {
66
+ valid: false,
67
+ error: `Title contains non-ASCII character at position ${i}: "${title[i]}"`,
68
+ }
69
+ }
70
+ }
71
+
72
+ // Check length (AEA_TITLE_SIZE includes null terminator)
73
+ const maxLength = AEA_TITLE_SIZE - 1
74
+ if (title.length > maxLength) {
75
+ return {
76
+ valid: false,
77
+ error: `Title is too long (${title.length} chars). Maximum allowed: ${maxLength} characters`,
78
+ }
79
+ }
80
+
81
+ return { valid: true }
82
+ }
83
+
84
+ /**
85
+ * Progress tracking utility for encoding/decoding operations
86
+ *
87
+ * Displays a progress bar with real-time performance metrics including:
88
+ * - Frame processing progress
89
+ * - Elapsed and remaining time estimates
90
+ * - Real-time processing speed multiplier
91
+ *
92
+ * @class
93
+ */
94
+ class ProgressTracker {
95
+ /**
96
+ * Create a progress tracker
97
+ *
98
+ * @param {number} frameCount - Total number of frames to process
99
+ * @param {string} operation - Operation name ('Encoding' or 'Decoding')
100
+ * @param {boolean} [quiet=false] - Suppress progress display
101
+ */
102
+ constructor(frameCount, operation, quiet = false) {
103
+ this.totalFrames = frameCount
104
+ this.quiet = quiet
105
+ this.startTime = performance.now()
106
+ this.frameCount = 0
107
+
108
+ if (!quiet) {
109
+ this.bar = new cliProgress.SingleBar(
110
+ {
111
+ autopadding: true,
112
+ format: `${operation} |{bar}| {percentage}% | {value}/{total} frames | {elapsed}/{remaining} | RT: {speed}x`,
113
+ },
114
+ cliProgress.Presets.rect
115
+ )
116
+ this.bar.start(frameCount, 0)
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Update progress with a new processed frame
122
+ *
123
+ * @param {number} sampleRate - Audio sample rate for RT speed calculation
124
+ */
125
+ update(sampleRate) {
126
+ this.frameCount++
127
+ if (!this.quiet) {
128
+ const elapsed = (performance.now() - this.startTime) / 1000
129
+ const audioProcessed = (this.frameCount * SAMPLES_PER_FRAME) / sampleRate
130
+ const rtSpeed = elapsed > 0 ? audioProcessed / elapsed : 0
131
+ const progress = this.frameCount / this.totalFrames
132
+ const estimatedTotal = progress > 0 ? elapsed / progress : 0
133
+ const remaining = Math.max(0, estimatedTotal - elapsed)
134
+
135
+ this.bar.update(this.frameCount, {
136
+ speed: rtSpeed.toFixed(1),
137
+ elapsed: formatTime(elapsed),
138
+ remaining: formatTime(remaining),
139
+ })
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Stop the progress bar display
145
+ */
146
+ stop() {
147
+ if (!this.quiet && this.bar) {
148
+ this.bar.stop()
149
+ }
150
+ }
151
+ }
152
+
153
+ /**
154
+ * WAV file reader with streaming support
155
+ *
156
+ * Provides async iteration over WAV file frames for memory-efficient
157
+ * processing of large audio files. Automatically handles:
158
+ * - Multi-channel deinterleaving
159
+ * - Bit depth conversion to float32
160
+ * - Frame padding for the last partial frame
161
+ *
162
+ * @class
163
+ */
164
+ class WavReader {
165
+ /**
166
+ * Create a WAV file reader
167
+ *
168
+ * @param {string} filePath - Path to the WAV file
169
+ */
170
+ constructor(filePath) {
171
+ this.filePath = filePath
172
+ this.channels = -1
173
+ this.sampleRate = -1
174
+ this.bitDepth = -1
175
+ this.totalSamples = -1
176
+ this.duration = -1
177
+ }
178
+
179
+ /**
180
+ * Load WAV file metadata without reading audio data
181
+ *
182
+ * Parses the WAV header to extract format information and calculates
183
+ * total duration based on file size.
184
+ *
185
+ * @returns {Promise<void>} Resolves when metadata is loaded
186
+ * @throws {Error} If file cannot be read or has invalid format
187
+ */
188
+ async loadMetadata() {
189
+ const stream = fs.createReadStream(this.filePath)
190
+ const reader = new wav.Reader()
191
+ stream.pipe(reader)
192
+
193
+ return new Promise((resolve, reject) => {
194
+ reader.on('format', (format) => {
195
+ this.channels = format.channels
196
+ this.sampleRate = format.sampleRate
197
+ this.bitDepth = format.bitDepth
198
+
199
+ const fileStats = fs.statSync(this.filePath)
200
+ const wavHeaderSize = 44
201
+ const dataSize = fileStats.size - wavHeaderSize
202
+ const bytesPerSample = this.bitDepth / 8
203
+
204
+ if (this.channels > 0 && bytesPerSample > 0) {
205
+ const totalChannelSamples = dataSize / bytesPerSample
206
+ this.totalSamples = Math.floor(totalChannelSamples / this.channels)
207
+ this.duration = this.totalSamples / this.sampleRate
208
+ }
209
+
210
+ if (this.sampleRate !== SAMPLE_RATE) {
211
+ console.log(
212
+ `Warning: Input sample rate ${this.sampleRate}Hz, expected ${SAMPLE_RATE}Hz`
213
+ )
214
+ }
215
+
216
+ stream.destroy()
217
+ resolve()
218
+ })
219
+ reader.on('error', reject)
220
+ stream.on('error', reject)
221
+ })
222
+ }
223
+
224
+ /**
225
+ * Async iterator for streaming WAV file frames
226
+ *
227
+ * @param {number} [frameSize=SAMPLES_PER_FRAME] - Samples per frame
228
+ * @yields {Float32Array|Array<Float32Array>} Audio frame data
229
+ * - Mono: Float32Array of samples
230
+ * - Stereo: [left, right] Float32Arrays
231
+ */
232
+ async *[Symbol.asyncIterator](frameSize = SAMPLES_PER_FRAME) {
233
+ const fileStream = fs.createReadStream(this.filePath)
234
+ const wavReader = new wav.Reader()
235
+ fileStream.pipe(wavReader)
236
+
237
+ let pcmQueue = []
238
+ let streamEnded = false
239
+ let streamError = null
240
+ let resolveDataPromise = null
241
+
242
+ wavReader.on('data', (chunk) => {
243
+ pcmQueue.push(chunk)
244
+ if (resolveDataPromise) {
245
+ resolveDataPromise()
246
+ resolveDataPromise = null
247
+ }
248
+ })
249
+
250
+ wavReader.on('end', () => {
251
+ streamEnded = true
252
+ if (resolveDataPromise) {
253
+ resolveDataPromise()
254
+ resolveDataPromise = null
255
+ }
256
+ })
257
+
258
+ wavReader.on('error', (err) => {
259
+ streamError = err
260
+ if (resolveDataPromise) {
261
+ resolveDataPromise()
262
+ resolveDataPromise = null
263
+ }
264
+ })
265
+
266
+ let buffer = Buffer.alloc(0)
267
+ const bytesPerSample = this.bitDepth / 8
268
+ const frameByteSize = frameSize * this.channels * bytesPerSample
269
+
270
+ while (true) {
271
+ if (streamError) throw streamError
272
+
273
+ while (pcmQueue.length > 0) {
274
+ buffer = Buffer.concat([buffer, ...pcmQueue])
275
+ pcmQueue = []
276
+ }
277
+
278
+ while (buffer.length >= frameByteSize) {
279
+ const frameBuffer = buffer.slice(0, frameByteSize)
280
+ buffer = buffer.slice(frameByteSize)
281
+ yield this._processFrameBuffer(frameBuffer, frameSize)
282
+ }
283
+
284
+ if (streamEnded && pcmQueue.length === 0) {
285
+ if (buffer.length > 0) {
286
+ const paddedFrame = Buffer.concat([
287
+ buffer,
288
+ Buffer.alloc(frameByteSize - buffer.length),
289
+ ])
290
+ yield this._processFrameBuffer(paddedFrame, frameSize)
291
+ }
292
+ break
293
+ }
294
+
295
+ if (!streamEnded) {
296
+ await new Promise((resolve) => {
297
+ resolveDataPromise = resolve
298
+ if (pcmQueue.length > 0 || streamEnded) {
299
+ resolveDataPromise = null
300
+ resolve()
301
+ }
302
+ })
303
+ }
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Process raw PCM buffer into float32 frame data
309
+ *
310
+ * @private
311
+ * @param {Buffer} frameBuffer - Raw PCM data
312
+ * @param {number} frameSize - Number of samples per channel
313
+ * @returns {Float32Array|Array<Float32Array>} Processed frame data
314
+ */
315
+ _processFrameBuffer(frameBuffer, frameSize) {
316
+ if (this.channels === 1) {
317
+ const frame = new Float32Array(frameSize)
318
+ for (let i = 0; i < frameSize; i++) {
319
+ frame[i] = this._sampleToFloat(frameBuffer, i * (this.bitDepth / 8))
320
+ }
321
+ return frame
322
+ } else {
323
+ const left = new Float32Array(frameSize)
324
+ const right = new Float32Array(frameSize)
325
+ for (let i = 0; i < frameSize; i++) {
326
+ const offset = i * this.channels * (this.bitDepth / 8)
327
+ left[i] = this._sampleToFloat(frameBuffer, offset)
328
+ right[i] = this._sampleToFloat(frameBuffer, offset + this.bitDepth / 8)
329
+ }
330
+ return [left, right]
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Convert PCM sample to normalized float32 value
336
+ *
337
+ * @private
338
+ * @param {Buffer} buffer - PCM data buffer
339
+ * @param {number} offset - Byte offset to sample
340
+ * @returns {number} Normalized float value [-1.0, 1.0]
341
+ */
342
+ _sampleToFloat(buffer, offset) {
343
+ if (this.bitDepth === 16) return buffer.readInt16LE(offset) / 32768.0
344
+ if (this.bitDepth === 24) {
345
+ let s =
346
+ buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16)
347
+ if (s > 0x7fffff) s -= 0x1000000
348
+ return s / 8388608.0
349
+ }
350
+ if (this.bitDepth === 32) return buffer.readInt32LE(offset) / 2147483648.0
351
+ return 0
352
+ }
353
+ }
354
+
355
+ /**
356
+ * Encode WAV file to ATRAC1 AEA format
357
+ *
358
+ * Performs streaming encoding with real-time progress tracking.
359
+ * Automatically detects mono/stereo format and applies appropriate
360
+ * compression settings.
361
+ *
362
+ * @param {string} inputFile - Path to input WAV file
363
+ * @param {string} outputFile - Path to output AEA file
364
+ * @param {Object} options - CLI options
365
+ * @param {boolean} options.quiet - Suppress progress output
366
+ * @param {string} [options.title] - Custom title for AEA file metadata
367
+ * @returns {Promise<void>} Resolves when encoding is complete
368
+ * @throws {Error} If input file has unsupported format
369
+ */
370
+ async function encodeFile(inputFile, outputFile, options) {
371
+ const reader = new WavReader(inputFile)
372
+ await reader.loadMetadata()
373
+
374
+ if (reader.channels !== 1 && reader.channels !== 2) {
375
+ throw new Error(`Unsupported channel count: ${reader.channels}`)
376
+ }
377
+
378
+ const frameCount = Math.ceil(reader.totalSamples / SAMPLES_PER_FRAME)
379
+
380
+ // Validate title if provided
381
+ if (options.title) {
382
+ const validation = validateTitle(options.title)
383
+ if (!validation.valid) {
384
+ throw new Error(validation.error)
385
+ }
386
+ }
387
+
388
+ if (!options.quiet) {
389
+ const bitrate = Math.round((BITRATE_PER_CHANNEL * reader.channels) / 1000)
390
+ console.log(
391
+ `${inputFile} (WAV ${reader.sampleRate}Hz ${
392
+ reader.channels
393
+ }ch ${formatTime(reader.duration)}) → ` +
394
+ `${outputFile} (AEA ${bitrate}kbps)`
395
+ )
396
+ }
397
+
398
+ const progress = new ProgressTracker(frameCount, 'Encoding', options.quiet)
399
+
400
+ try {
401
+ const onProgress = () => {
402
+ progress.update(reader.sampleRate)
403
+ }
404
+
405
+ const encodedFrames = AudioProcessor.encodeStream(reader, {
406
+ channelCount: reader.channels,
407
+ onProgress,
408
+ })
409
+
410
+ const title = options.title || path.basename(outputFile, '.aea')
411
+ const blob = await AudioProcessor.createAeaBlob(encodedFrames, {
412
+ title,
413
+ channelCount: reader.channels,
414
+ })
415
+
416
+ const buffer = await blob.arrayBuffer()
417
+ await fs.promises.writeFile(outputFile, new Uint8Array(buffer))
418
+ } finally {
419
+ progress.stop()
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Decode ATRAC1 AEA file to WAV format
425
+ *
426
+ * Performs streaming decoding with real-time progress tracking.
427
+ * Reconstructs PCM audio from compressed ATRAC1 format.
428
+ *
429
+ * @param {string} inputFile - Path to input AEA file
430
+ * @param {string} outputFile - Path to output WAV file
431
+ * @param {Object} options - CLI options
432
+ * @param {boolean} options.quiet - Suppress progress output
433
+ * @returns {Promise<void>} Resolves when decoding is complete
434
+ * @throws {Error} If input file is invalid or corrupted
435
+ */
436
+ async function decodeFile(inputFile, outputFile, options) {
437
+ const reader = new AeaReader(inputFile)
438
+ await reader.loadMetadata()
439
+ const { metadata } = reader
440
+
441
+ const outputFrames = Math.floor(metadata.frameCount / metadata.channelCount)
442
+ const duration = (outputFrames * SAMPLES_PER_FRAME) / SAMPLE_RATE
443
+
444
+ if (!options.quiet) {
445
+ const bitrate = Math.round(
446
+ (BITRATE_PER_CHANNEL * metadata.channelCount) / 1000
447
+ )
448
+ console.log(
449
+ `${inputFile} (AEA ${bitrate}kbps ${metadata.channelCount}ch ${formatTime(
450
+ duration
451
+ )}) → ` + `${outputFile} (WAV ${SAMPLE_RATE}Hz)`
452
+ )
453
+ }
454
+
455
+ const progress = new ProgressTracker(outputFrames, 'Decoding', options.quiet)
456
+
457
+ try {
458
+ const onProgress = () => {
459
+ progress.update(SAMPLE_RATE)
460
+ }
461
+
462
+ const decodedFrames = AudioProcessor.decodeStream(reader, {
463
+ channelCount: metadata.channelCount,
464
+ onProgress,
465
+ })
466
+
467
+ const pcmFrames = await AudioProcessor.collectFrames(decodedFrames)
468
+ const wavBlob = AudioProcessor.createWavBlob(
469
+ pcmFrames,
470
+ metadata.channelCount,
471
+ SAMPLE_RATE
472
+ )
473
+
474
+ const buffer = await wavBlob.arrayBuffer()
475
+ await fs.promises.writeFile(outputFile, new Uint8Array(buffer))
476
+ } finally {
477
+ progress.stop()
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Dump AEA file structure to JSON format
483
+ *
484
+ * Extracts and exports all metadata and frame information from an AEA file
485
+ * for inspection and analysis. Includes header info, frame structure,
486
+ * and statistical analysis.
487
+ *
488
+ * @param {string} inputFile - Path to input AEA file
489
+ * @param {string} outputFile - Path to output JSON file
490
+ * @param {Object} options - CLI options
491
+ * @param {boolean} options.quiet - Suppress progress output
492
+ * @returns {Promise<void>} Resolves when dump is complete
493
+ * @throws {Error} If input file is invalid or cannot be read
494
+ */
495
+ async function dumpFile(inputFile, outputFile, options) {
496
+ const stats = fs.statSync(inputFile)
497
+
498
+ if (!options.quiet) {
499
+ console.log(`${inputFile} (AEA) → ${outputFile} (JSON)`)
500
+ }
501
+
502
+ const handle = await fs.promises.open(inputFile, 'r')
503
+ const headerBuffer = Buffer.alloc(AEA_HEADER_SIZE)
504
+ await handle.read(headerBuffer, 0, AEA_HEADER_SIZE, 0)
505
+
506
+ let metadata
507
+ try {
508
+ metadata = AeaFile.parseHeader(headerBuffer)
509
+ } catch (err) {
510
+ throw new Error(`Invalid AEA header: ${err.message}`)
511
+ }
512
+
513
+ const frameCount = Math.floor(
514
+ (stats.size - AEA_HEADER_SIZE) / SOUND_UNIT_SIZE
515
+ )
516
+
517
+ const dump = {
518
+ file: {
519
+ path: inputFile,
520
+ size: stats.size,
521
+ headerSize: AEA_HEADER_SIZE,
522
+ dataSize: stats.size - AEA_HEADER_SIZE,
523
+ expectedDataSize: frameCount * SOUND_UNIT_SIZE,
524
+ sizeDifference:
525
+ stats.size - AEA_HEADER_SIZE - frameCount * SOUND_UNIT_SIZE,
526
+ },
527
+ header: {
528
+ magic: Array.from(headerBuffer.slice(0, 4))
529
+ .map((b) => b.toString(16).padStart(2, '0'))
530
+ .join(' '),
531
+ title: metadata.title,
532
+ frameCount: metadata.frameCount,
533
+ channelCount: metadata.channelCount,
534
+ framesPerChannel: metadata.frameCount / metadata.channelCount,
535
+ duration: ((metadata.frameCount / metadata.channelCount) * 512) / 44100,
536
+ },
537
+ frames: [],
538
+ }
539
+
540
+ const progress = new ProgressTracker(frameCount, 'Analyzing', options.quiet)
541
+
542
+ try {
543
+ let offset = AEA_HEADER_SIZE
544
+ for (let i = 0; i < frameCount; i++) {
545
+ const frameBuffer = Buffer.alloc(SOUND_UNIT_SIZE)
546
+ await handle.read(frameBuffer, 0, SOUND_UNIT_SIZE, offset)
547
+
548
+ try {
549
+ const frame = deserializeFrame(frameBuffer)
550
+ const frameInfo = {
551
+ index: i,
552
+ offset: offset,
553
+ offsetHex: '0x' + offset.toString(16),
554
+ nBfu: frame.nBfu,
555
+ blockSizeMode: frame.blockSizeMode,
556
+ scaleFactorIndices: Array.from(frame.scaleFactorIndices),
557
+ wordLengthIndices: Array.from(frame.wordLengthIndices),
558
+ hasNonZeroScaleFactors: frame.scaleFactorIndices.some((sf) => sf > 0),
559
+ hasNonZeroWordLengths: frame.wordLengthIndices.some((wl) => wl > 0),
560
+ hasCoefficients: frame.quantizedCoefficients.some(
561
+ (coeffs) => coeffs && coeffs.some((c) => c !== 0)
562
+ ),
563
+ }
564
+
565
+ // Include full coefficient data for first few frames only
566
+ if (i < 3 || i === frameCount - 1) {
567
+ frameInfo.quantizedCoefficients = frame.quantizedCoefficients.map(
568
+ (coeffs) => (coeffs ? Array.from(coeffs) : null)
569
+ )
570
+ }
571
+
572
+ dump.frames.push(frameInfo)
573
+ } catch (err) {
574
+ dump.frames.push({
575
+ index: i,
576
+ offset: offset,
577
+ offsetHex: '0x' + offset.toString(16),
578
+ error: err.message,
579
+ })
580
+ }
581
+
582
+ progress.update(SAMPLE_RATE)
583
+ offset += SOUND_UNIT_SIZE
584
+ }
585
+ } finally {
586
+ progress.stop()
587
+ await handle.close()
588
+ }
589
+
590
+ // Add summary statistics
591
+ dump.summary = {
592
+ totalFrames: frameCount,
593
+ validFrames: dump.frames.filter((f) => !f.error).length,
594
+ errorFrames: dump.frames.filter((f) => f.error).length,
595
+ silentFrames: dump.frames.filter(
596
+ (f) => !f.error && !f.hasNonZeroScaleFactors && !f.hasNonZeroWordLengths
597
+ ).length,
598
+ activeFrames: dump.frames.filter(
599
+ (f) => !f.error && (f.hasNonZeroScaleFactors || f.hasNonZeroWordLengths)
600
+ ).length,
601
+ }
602
+
603
+ const jsonOutput = JSON.stringify(dump, null, 2)
604
+ await fs.promises.writeFile(outputFile, jsonOutput)
605
+ }
606
+
607
+ /**
608
+ * Main CLI entry point
609
+ *
610
+ * Parses command line arguments and executes encoding, decoding,
611
+ * or dump operations with error handling and validation.
612
+ *
613
+ * @returns {Promise<void>} Resolves when operation is complete
614
+ */
615
+ async function main() {
616
+ program
617
+ .name('carta1')
618
+ .description('ATRAC1 Audio Codec')
619
+ .version('1.0.0')
620
+ .option('-e, --encode', 'Encode WAV to AEA')
621
+ .option('-d, --decode', 'Decode AEA to WAV')
622
+ .option('-j, --json', 'Dump AEA file structure to JSON')
623
+ .option('-q, --quiet', 'Suppress all output except errors')
624
+ .option('-f, --force', 'Overwrite output file if it exists')
625
+ .option(
626
+ '-t, --title <title>',
627
+ 'Custom title for AEA file metadata (encoding only)'
628
+ )
629
+ .argument('<input>', 'Input file path')
630
+ .argument('<output>', 'Output file path')
631
+ .parse()
632
+
633
+ const options = program.opts()
634
+ const [inputFile, outputFile] = program.args
635
+
636
+ // Validate operation mode
637
+ const modes = [options.encode, options.decode, options.json].filter(Boolean)
638
+ if (modes.length === 0) {
639
+ console.error('Error: Must specify one of --encode, --decode, or --json')
640
+ process.exit(1)
641
+ }
642
+ if (modes.length > 1) {
643
+ console.error('Error: Cannot specify multiple operation modes')
644
+ process.exit(1)
645
+ }
646
+
647
+ if (fs.existsSync(outputFile) && !options.force) {
648
+ console.error(
649
+ `Error: Output file '${outputFile}' already exists. Use --force to overwrite.`
650
+ )
651
+ process.exit(1)
652
+ }
653
+
654
+ try {
655
+ if (options.encode) {
656
+ await encodeFile(inputFile, outputFile, options)
657
+ } else if (options.decode) {
658
+ await decodeFile(inputFile, outputFile, options)
659
+ } else if (options.json) {
660
+ await dumpFile(inputFile, outputFile, options)
661
+ }
662
+ } catch (error) {
663
+ if (error.code === 'ENOENT') {
664
+ console.error(`Error: File not found - ${error.path}`)
665
+ } else {
666
+ console.error(`Error: ${error.message}`)
667
+ if (!options.quiet) {
668
+ console.error(error.stack)
669
+ }
670
+ }
671
+ process.exit(1)
672
+ }
673
+ }
674
+
675
+ main().catch((error) => {
676
+ console.error(`An unhandled error occurred: ${error.message}`)
677
+ console.error(error.stack)
678
+ process.exit(1)
679
+ })