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/LICENSE +15 -0
- package/README.md +129 -0
- package/bin/cli.js +679 -0
- package/codec/analysis/psychoacoustics.js +589 -0
- package/codec/analysis/transient.js +226 -0
- package/codec/browser/interface.js +188 -0
- package/codec/browser/worker.js +140 -0
- package/codec/coding/bitallocation.js +407 -0
- package/codec/coding/quantization.js +150 -0
- package/codec/core/bitstream.js +82 -0
- package/codec/core/buffers.js +99 -0
- package/codec/core/constants.js +321 -0
- package/codec/core/options.js +151 -0
- package/codec/index.js +27 -0
- package/codec/io/processor.js +566 -0
- package/codec/io/readers.js +56 -0
- package/codec/io/serialization.js +254 -0
- package/codec/pipeline/decoder.js +278 -0
- package/codec/pipeline/encoder.js +369 -0
- package/codec/transforms/fft.js +69 -0
- package/codec/transforms/mdct.js +225 -0
- package/codec/transforms/qmf.js +127 -0
- package/codec/utils.js +151 -0
- package/dist/carta1-worker-interface.min.js +2 -0
- package/dist/carta1-worker-interface.min.js.map +1 -0
- package/dist/carta1-worker.min.js +2 -0
- package/dist/carta1-worker.min.js.map +1 -0
- package/dist/carta1.min.js +2 -0
- package/dist/carta1.min.js.map +1 -0
- package/package.json +77 -0
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
|
+
})
|