carta1 1.4.0 → 1.6.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/README.md +57 -2
- package/bin/cli.js +17 -180
- package/bin/options.js +41 -0
- package/codec/boundary/inspect.js +96 -0
- package/codec/boundary/pcm.js +86 -0
- package/codec/boundary/stream.js +48 -54
- package/codec/boundary/wme.js +189 -0
- package/dist/carta1-worker.min.js +1 -1
- package/dist/carta1-worker.min.js.map +1 -1
- package/dist/carta1.min.js +1 -1
- package/dist/carta1.min.js.map +1 -1
- package/dist/carta1.wme +0 -0
- package/dist/wme/index.js +2 -0
- package/dist/wme/index.js.map +1 -0
- package/docs/native.md +107 -0
- package/docs/porffor.md +23 -0
- package/package.json +16 -7
package/README.md
CHANGED
|
@@ -34,6 +34,11 @@ npm ci
|
|
|
34
34
|
npm run check
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
+
[Native CLI archives](docs/native.md) for Linux and macOS (x64 and ARM64) are
|
|
38
|
+
built with Porffor and attached to GitHub Releases after exact WAV/AEA parity
|
|
39
|
+
checks against the Node CLI. They run without Node.js. See the native guide
|
|
40
|
+
for local builds, supported input formats, and verification.
|
|
41
|
+
|
|
37
42
|
## CLI
|
|
38
43
|
|
|
39
44
|
Run the installed executable directly, or use `npx carta1` from a project that
|
|
@@ -184,8 +189,8 @@ npm test # Run the test suite once
|
|
|
184
189
|
npm run test:watch # Re-run tests while editing
|
|
185
190
|
npm run lint # Check JavaScript and formatting
|
|
186
191
|
npm run format # Apply repository formatting
|
|
187
|
-
npm run build # Build the
|
|
188
|
-
npm run check # Run lint, tests, and
|
|
192
|
+
npm run build # Build browser bundles and the WME plugin
|
|
193
|
+
npm run check # Run lint, tests, builds, and packaged WME checks
|
|
189
194
|
```
|
|
190
195
|
|
|
191
196
|
Run `npm run check` before submitting a change. Pull requests and pushes run
|
|
@@ -201,3 +206,53 @@ implementation used to understand the ATRAC1 format and codec behavior.
|
|
|
201
206
|
## License
|
|
202
207
|
|
|
203
208
|
ISC
|
|
209
|
+
|
|
210
|
+
## Experimental Web MiniDisc encoder plugin
|
|
211
|
+
|
|
212
|
+
`npm run build` also emits `dist/carta1.wme`, alongside the three existing
|
|
213
|
+
browser bundles. GitHub Releases attach the WME, all three JavaScript bundles,
|
|
214
|
+
and their source maps. The npm package includes the same build outputs. Release
|
|
215
|
+
CDN links are pinned to that release's version.
|
|
216
|
+
|
|
217
|
+
**Carta1 (ATRAC1, experimental)** is an ATRAC1 codec for standard MD SP, not
|
|
218
|
+
ATRAC3 LP2/LP4 or ATRAC3plus Hi-MD. Current Web MiniDisc API v1 does not support
|
|
219
|
+
software ATRAC1 output: its SP modes request PCM for the recorder to encode in
|
|
220
|
+
hardware. Installing this plugin does not enable Carta1 recording in the
|
|
221
|
+
unmodified app. All current API v1 codecs (PCM, MP3, AT3, A3+) are explicitly
|
|
222
|
+
reported as unsupported so there is no silent hardware-encoding fallback.
|
|
223
|
+
|
|
224
|
+
### Proposed host extension for testing
|
|
225
|
+
|
|
226
|
+
The archive uses the standard API v1 SAR container and default encoder class,
|
|
227
|
+
with the usual `init`, `deinit`, `getSupportFor`, `transcode`, and progress hooks.
|
|
228
|
+
It adds two **proposed, nonstandard** export codec names, using the host's
|
|
229
|
+
existing recording-mode names:
|
|
230
|
+
|
|
231
|
+
| `exportParams.format` | PCM channels | Output |
|
|
232
|
+
| -------------------------------- | ------------ | ---------------- |
|
|
233
|
+
| `{ codec: 'SPS', bitrate: 292 }` | 2 | ATRAC1 stereo SP |
|
|
234
|
+
| `{ codec: 'SPM', bitrate: 146 }` | 1 | ATRAC1 mono SP |
|
|
235
|
+
|
|
236
|
+
Bitrates are nominal kbps; ATRAC1 uses 212-byte sound units per channel per
|
|
237
|
+
512 samples at 44.1 kHz. The host's built-in FFmpeg decodes/resamples sources
|
|
238
|
+
and optionally applies ReplayGain; the existing Carta1 worker encodes them.
|
|
239
|
+
`transcode` returns raw sound units **without the 2048-byte AEA header**.
|
|
240
|
+
Stereo sound units alternate left/right. Canonical priming and drain units
|
|
241
|
+
are retained; gapless is not advertised and AEA timeline metadata is omitted.
|
|
242
|
+
|
|
243
|
+
A host implementing this proposal must extend its export-codec interface,
|
|
244
|
+
preserve SPS/SPM through capability checks and transcoding instead of mapping
|
|
245
|
+
them to PCM, and route the result to a compatible pre-encoded ATRAC1 transfer
|
|
246
|
+
path (such as a supported device's homebrew SP uploader). It must handle channel
|
|
247
|
+
mode, transfer framing/padding, and device capabilities there. Ordinary SP PCM
|
|
248
|
+
upload must never receive these compressed bytes. Neither the proposed names
|
|
249
|
+
nor device transfer compatibility are part of the current WMD API contract;
|
|
250
|
+
the WMD team can revise them when implementing support. No host changes are
|
|
251
|
+
included here.
|
|
252
|
+
|
|
253
|
+
`npm run test:wme` exercises the built archive and its bundled worker for both
|
|
254
|
+
proposed formats, comparing the returned raw bytes with `encodeAeaPcm` across
|
|
255
|
+
independent tracks. Lifecycle and failure tests run in the normal test suite.
|
|
256
|
+
|
|
257
|
+
The repository-only WME packager lives in `build/wme.js`. Build tooling is
|
|
258
|
+
excluded from the npm package; the generated `dist/` assets are included.
|
package/bin/cli.js
CHANGED
|
@@ -13,16 +13,18 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { program } from 'commander'
|
|
16
|
+
import { validateModes } from './options.js'
|
|
17
|
+
import { inspectAea } from '../codec/boundary/inspect.js'
|
|
16
18
|
import fs from 'fs'
|
|
17
19
|
import path from 'path'
|
|
18
20
|
import { performance } from 'perf_hooks'
|
|
19
21
|
import cliProgress from 'cli-progress'
|
|
20
22
|
import wav from 'wav'
|
|
21
23
|
|
|
24
|
+
import { pcmBytesToChannels } from '../codec/boundary/pcm.js'
|
|
22
25
|
import { AudioProcessor } from '../codec/boundary/processor.js'
|
|
23
26
|
import { AeaReader } from '../codec/boundary/reader.js'
|
|
24
|
-
import {
|
|
25
|
-
import { deserializeFrame } from '../codec/syntax/frame.js'
|
|
27
|
+
import { validateAeaTitle } from '../codec/boundary/container.js'
|
|
26
28
|
import { EncoderOptions } from '../codec/core/options.js'
|
|
27
29
|
import {
|
|
28
30
|
SAMPLE_RATE,
|
|
@@ -67,47 +69,6 @@ function validateTitle(title) {
|
|
|
67
69
|
* @returns {Array<number>} [returns.modes] - Parsed modes if valid
|
|
68
70
|
* @returns {string} [returns.error] - Error message if invalid
|
|
69
71
|
*/
|
|
70
|
-
function validateModes(modes) {
|
|
71
|
-
if (!modes) {
|
|
72
|
-
return { valid: true }
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const parts = modes.split(',')
|
|
76
|
-
if (parts.length !== 3) {
|
|
77
|
-
return {
|
|
78
|
-
valid: false,
|
|
79
|
-
error: `Modes must have exactly 3 values separated by commas, got ${parts.length}`,
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const parsedModes = []
|
|
84
|
-
const validValues = [
|
|
85
|
-
[0, 2], // Position 1: 0 or 2
|
|
86
|
-
[0, 2], // Position 2: 0 or 2
|
|
87
|
-
[0, 3], // Position 3: 0 or 3
|
|
88
|
-
]
|
|
89
|
-
|
|
90
|
-
for (let i = 0; i < 3; i++) {
|
|
91
|
-
const mode = parseInt(parts[i], 10)
|
|
92
|
-
if (isNaN(mode)) {
|
|
93
|
-
return {
|
|
94
|
-
valid: false,
|
|
95
|
-
error: `Mode at position ${i + 1} is not a number: "${parts[i]}"`,
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
if (!validValues[i].includes(mode)) {
|
|
100
|
-
return {
|
|
101
|
-
valid: false,
|
|
102
|
-
error: `Mode at position ${i + 1} must be ${validValues[i].join(' or ')}, got ${mode}`,
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
parsedModes.push(mode)
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
return { valid: true, modes: parsedModes }
|
|
110
|
-
}
|
|
111
72
|
|
|
112
73
|
/**
|
|
113
74
|
* Progress tracking utility for encoding/decoding operations
|
|
@@ -341,42 +302,13 @@ class WavReader {
|
|
|
341
302
|
* @returns {Float32Array|Array<Float32Array>} Processed frame data
|
|
342
303
|
*/
|
|
343
304
|
_processFrameBuffer(frameBuffer, frameSize) {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
const left = new Float32Array(frameSize)
|
|
352
|
-
const right = new Float32Array(frameSize)
|
|
353
|
-
for (let i = 0; i < frameSize; i++) {
|
|
354
|
-
const offset = i * this.channels * (this.bitDepth / 8)
|
|
355
|
-
left[i] = this._sampleToFloat(frameBuffer, offset)
|
|
356
|
-
right[i] = this._sampleToFloat(frameBuffer, offset + this.bitDepth / 8)
|
|
357
|
-
}
|
|
358
|
-
return [left, right]
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/**
|
|
363
|
-
* Convert PCM sample to normalized float32 value
|
|
364
|
-
*
|
|
365
|
-
* @private
|
|
366
|
-
* @param {Buffer} buffer - PCM data buffer
|
|
367
|
-
* @param {number} offset - Byte offset to sample
|
|
368
|
-
* @returns {number} Normalized float value [-1.0, 1.0]
|
|
369
|
-
*/
|
|
370
|
-
_sampleToFloat(buffer, offset) {
|
|
371
|
-
if (this.bitDepth === 16) return buffer.readInt16LE(offset) / 32768.0
|
|
372
|
-
if (this.bitDepth === 24) {
|
|
373
|
-
let s =
|
|
374
|
-
buffer[offset] | (buffer[offset + 1] << 8) | (buffer[offset + 2] << 16)
|
|
375
|
-
if (s > 0x7fffff) s -= 0x1000000
|
|
376
|
-
return s / 8388608.0
|
|
377
|
-
}
|
|
378
|
-
if (this.bitDepth === 32) return buffer.readInt32LE(offset) / 2147483648.0
|
|
379
|
-
return 0
|
|
305
|
+
const channels = pcmBytesToChannels(
|
|
306
|
+
frameBuffer,
|
|
307
|
+
this.bitDepth,
|
|
308
|
+
this.channels,
|
|
309
|
+
frameSize
|
|
310
|
+
)
|
|
311
|
+
return this.channels === 1 ? channels[0] : channels
|
|
380
312
|
}
|
|
381
313
|
}
|
|
382
314
|
|
|
@@ -544,115 +476,20 @@ async function decodeFile(inputFile, outputFile, options) {
|
|
|
544
476
|
* @throws {Error} If input file is invalid or cannot be read
|
|
545
477
|
*/
|
|
546
478
|
async function dumpFile(inputFile, outputFile, options) {
|
|
547
|
-
const
|
|
548
|
-
|
|
549
|
-
if (!options.quiet) {
|
|
550
|
-
console.log(`${inputFile} (AEA) → ${outputFile} (JSON)`)
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
const handle = await fs.promises.open(inputFile, 'r')
|
|
554
|
-
const headerBuffer = Buffer.alloc(AEA_HEADER_SIZE)
|
|
555
|
-
await handle.read(headerBuffer, 0, AEA_HEADER_SIZE, 0)
|
|
556
|
-
|
|
557
|
-
let metadata
|
|
558
|
-
try {
|
|
559
|
-
metadata = AeaFile.parseHeader(headerBuffer)
|
|
560
|
-
} catch (err) {
|
|
561
|
-
throw new Error(`Invalid AEA header: ${err.message}`)
|
|
562
|
-
}
|
|
563
|
-
|
|
479
|
+
const bytes = await fs.promises.readFile(inputFile)
|
|
480
|
+
if (!options.quiet) console.log(`${inputFile} (AEA) → ${outputFile} (JSON)`)
|
|
564
481
|
const frameCount = Math.floor(
|
|
565
|
-
(
|
|
482
|
+
(bytes.length - AEA_HEADER_SIZE) / SOUND_UNIT_SIZE
|
|
566
483
|
)
|
|
567
|
-
|
|
568
|
-
const dump = {
|
|
569
|
-
file: {
|
|
570
|
-
path: inputFile,
|
|
571
|
-
size: stats.size,
|
|
572
|
-
headerSize: AEA_HEADER_SIZE,
|
|
573
|
-
dataSize: stats.size - AEA_HEADER_SIZE,
|
|
574
|
-
expectedDataSize: frameCount * SOUND_UNIT_SIZE,
|
|
575
|
-
sizeDifference:
|
|
576
|
-
stats.size - AEA_HEADER_SIZE - frameCount * SOUND_UNIT_SIZE,
|
|
577
|
-
},
|
|
578
|
-
header: {
|
|
579
|
-
magic: Array.from(headerBuffer.slice(0, 4))
|
|
580
|
-
.map((b) => b.toString(16).padStart(2, '0'))
|
|
581
|
-
.join(' '),
|
|
582
|
-
title: metadata.title,
|
|
583
|
-
frameCount: metadata.frameCount,
|
|
584
|
-
channelCount: metadata.channelCount,
|
|
585
|
-
framesPerChannel: metadata.frameCount / metadata.channelCount,
|
|
586
|
-
duration: ((metadata.frameCount / metadata.channelCount) * 512) / 44100,
|
|
587
|
-
},
|
|
588
|
-
frames: [],
|
|
589
|
-
}
|
|
590
|
-
|
|
591
484
|
const progress = new ProgressTracker(frameCount, 'Analyzing', options.quiet)
|
|
592
|
-
|
|
593
485
|
try {
|
|
594
|
-
|
|
595
|
-
for (let i = 0; i < frameCount; i++) {
|
|
596
|
-
const frameBuffer = Buffer.alloc(SOUND_UNIT_SIZE)
|
|
597
|
-
await handle.read(frameBuffer, 0, SOUND_UNIT_SIZE, offset)
|
|
598
|
-
|
|
599
|
-
try {
|
|
600
|
-
const frame = deserializeFrame(frameBuffer)
|
|
601
|
-
const frameInfo = {
|
|
602
|
-
index: i,
|
|
603
|
-
offset: offset,
|
|
604
|
-
offsetHex: '0x' + offset.toString(16),
|
|
605
|
-
nBfu: frame.nBfu,
|
|
606
|
-
blockModes: frame.blockModes,
|
|
607
|
-
scaleFactorIndices: Array.from(frame.scaleFactorIndices),
|
|
608
|
-
wordLengthIndices: Array.from(frame.wordLengthIndices),
|
|
609
|
-
hasNonZeroScaleFactors: frame.scaleFactorIndices.some((sf) => sf > 0),
|
|
610
|
-
hasNonZeroWordLengths: frame.wordLengthIndices.some((wl) => wl > 0),
|
|
611
|
-
hasCoefficients: frame.quantizedCoefficients.some(
|
|
612
|
-
(coeffs) => coeffs && coeffs.some((c) => c !== 0)
|
|
613
|
-
),
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
// Include full coefficient data for first few frames only
|
|
617
|
-
if (i < 3 || i === frameCount - 1) {
|
|
618
|
-
frameInfo.quantizedCoefficients = frame.quantizedCoefficients.map(
|
|
619
|
-
(coeffs) => (coeffs ? Array.from(coeffs) : null)
|
|
620
|
-
)
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
dump.frames.push(frameInfo)
|
|
624
|
-
} catch (err) {
|
|
625
|
-
dump.frames.push({
|
|
626
|
-
index: i,
|
|
627
|
-
offset: offset,
|
|
628
|
-
offsetHex: '0x' + offset.toString(16),
|
|
629
|
-
error: err.message,
|
|
630
|
-
})
|
|
631
|
-
}
|
|
632
|
-
|
|
486
|
+
const dump = inspectAea(bytes, inputFile, () =>
|
|
633
487
|
progress.update(SAMPLE_RATE)
|
|
634
|
-
|
|
635
|
-
|
|
488
|
+
)
|
|
489
|
+
await fs.promises.writeFile(outputFile, JSON.stringify(dump, null, 2))
|
|
636
490
|
} finally {
|
|
637
491
|
progress.stop()
|
|
638
|
-
await handle.close()
|
|
639
492
|
}
|
|
640
|
-
|
|
641
|
-
// Add summary statistics
|
|
642
|
-
dump.summary = {
|
|
643
|
-
totalFrames: frameCount,
|
|
644
|
-
validFrames: dump.frames.filter((f) => !f.error).length,
|
|
645
|
-
errorFrames: dump.frames.filter((f) => f.error).length,
|
|
646
|
-
silentFrames: dump.frames.filter(
|
|
647
|
-
(f) => !f.error && !f.hasNonZeroScaleFactors && !f.hasNonZeroWordLengths
|
|
648
|
-
).length,
|
|
649
|
-
activeFrames: dump.frames.filter(
|
|
650
|
-
(f) => !f.error && (f.hasNonZeroScaleFactors || f.hasNonZeroWordLengths)
|
|
651
|
-
).length,
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
const jsonOutput = JSON.stringify(dump, null, 2)
|
|
655
|
-
await fs.promises.writeFile(outputFile, jsonOutput)
|
|
656
493
|
}
|
|
657
494
|
|
|
658
495
|
/**
|
package/bin/options.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function validateModes(modes) {
|
|
2
|
+
if (!modes) {
|
|
3
|
+
return { valid: true }
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const parts = modes.split(',')
|
|
7
|
+
if (parts.length !== 3) {
|
|
8
|
+
return {
|
|
9
|
+
valid: false,
|
|
10
|
+
error: `Modes must have exactly 3 values separated by commas, got ${parts.length}`,
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const parsedModes = []
|
|
15
|
+
const validValues = [
|
|
16
|
+
[0, 2], // Position 1: 0 or 2
|
|
17
|
+
[0, 2], // Position 2: 0 or 2
|
|
18
|
+
[0, 3], // Position 3: 0 or 3
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
for (let i = 0; i < 3; i++) {
|
|
22
|
+
const mode = parseInt(parts[i], 10)
|
|
23
|
+
if (isNaN(mode)) {
|
|
24
|
+
return {
|
|
25
|
+
valid: false,
|
|
26
|
+
error: `Mode at position ${i + 1} is not a number: "${parts[i]}"`,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (!validValues[i].includes(mode)) {
|
|
31
|
+
return {
|
|
32
|
+
valid: false,
|
|
33
|
+
error: `Mode at position ${i + 1} must be ${validValues[i].join(' or ')}, got ${mode}`,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
parsedModes.push(mode)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { valid: true, modes: parsedModes }
|
|
41
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/** Shared JSON inspection for the Node and native hosts. */
|
|
2
|
+
import { AeaFile } from './container.js'
|
|
3
|
+
import { deserializeFrame } from '../syntax/frame.js'
|
|
4
|
+
import { AEA_HEADER_SIZE, SOUND_UNIT_SIZE } from '../core/constants.js'
|
|
5
|
+
|
|
6
|
+
export function inspectAea(bytes, inputFile, onFrame = () => {}) {
|
|
7
|
+
const stats = { size: bytes.length }
|
|
8
|
+
const headerBuffer = bytes.subarray(0, AEA_HEADER_SIZE)
|
|
9
|
+
let metadata
|
|
10
|
+
try {
|
|
11
|
+
metadata = AeaFile.parseHeader(headerBuffer)
|
|
12
|
+
} catch (err) {
|
|
13
|
+
throw new Error(`Invalid AEA header: ${err.message}`)
|
|
14
|
+
}
|
|
15
|
+
const frameCount = Math.floor(
|
|
16
|
+
(bytes.length - AEA_HEADER_SIZE) / SOUND_UNIT_SIZE
|
|
17
|
+
)
|
|
18
|
+
const dump = {
|
|
19
|
+
file: {
|
|
20
|
+
path: inputFile,
|
|
21
|
+
size: stats.size,
|
|
22
|
+
headerSize: AEA_HEADER_SIZE,
|
|
23
|
+
dataSize: stats.size - AEA_HEADER_SIZE,
|
|
24
|
+
expectedDataSize: frameCount * SOUND_UNIT_SIZE,
|
|
25
|
+
sizeDifference:
|
|
26
|
+
stats.size - AEA_HEADER_SIZE - frameCount * SOUND_UNIT_SIZE,
|
|
27
|
+
},
|
|
28
|
+
header: {
|
|
29
|
+
magic: Array.from(headerBuffer.slice(0, 4))
|
|
30
|
+
.map((b) => b.toString(16).padStart(2, '0'))
|
|
31
|
+
.join(' '),
|
|
32
|
+
title: metadata.title,
|
|
33
|
+
frameCount: metadata.frameCount,
|
|
34
|
+
channelCount: metadata.channelCount,
|
|
35
|
+
framesPerChannel: metadata.frameCount / metadata.channelCount,
|
|
36
|
+
duration: ((metadata.frameCount / metadata.channelCount) * 512) / 44100,
|
|
37
|
+
},
|
|
38
|
+
frames: [],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let offset = AEA_HEADER_SIZE
|
|
42
|
+
for (let i = 0; i < frameCount; i++) {
|
|
43
|
+
const frameBuffer = bytes.subarray(offset, offset + SOUND_UNIT_SIZE)
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
const frame = deserializeFrame(frameBuffer)
|
|
47
|
+
const frameInfo = {
|
|
48
|
+
index: i,
|
|
49
|
+
offset: offset,
|
|
50
|
+
offsetHex: '0x' + offset.toString(16),
|
|
51
|
+
nBfu: frame.nBfu,
|
|
52
|
+
blockModes: frame.blockModes,
|
|
53
|
+
scaleFactorIndices: Array.from(frame.scaleFactorIndices),
|
|
54
|
+
wordLengthIndices: Array.from(frame.wordLengthIndices),
|
|
55
|
+
hasNonZeroScaleFactors: frame.scaleFactorIndices.some((sf) => sf > 0),
|
|
56
|
+
hasNonZeroWordLengths: frame.wordLengthIndices.some((wl) => wl > 0),
|
|
57
|
+
hasCoefficients: frame.quantizedCoefficients.some(
|
|
58
|
+
(coeffs) => coeffs && coeffs.some((c) => c !== 0)
|
|
59
|
+
),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Include full coefficient data for first few frames only
|
|
63
|
+
if (i < 3 || i === frameCount - 1) {
|
|
64
|
+
frameInfo.quantizedCoefficients = frame.quantizedCoefficients.map(
|
|
65
|
+
(coeffs) => (coeffs ? Array.from(coeffs) : null)
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
dump.frames.push(frameInfo)
|
|
70
|
+
} catch (err) {
|
|
71
|
+
dump.frames.push({
|
|
72
|
+
index: i,
|
|
73
|
+
offset: offset,
|
|
74
|
+
offsetHex: '0x' + offset.toString(16),
|
|
75
|
+
error: err.message,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
onFrame()
|
|
80
|
+
offset += SOUND_UNIT_SIZE
|
|
81
|
+
}
|
|
82
|
+
// Add summary statistics
|
|
83
|
+
dump.summary = {
|
|
84
|
+
totalFrames: frameCount,
|
|
85
|
+
validFrames: dump.frames.filter((f) => !f.error).length,
|
|
86
|
+
errorFrames: dump.frames.filter((f) => f.error).length,
|
|
87
|
+
silentFrames: dump.frames.filter(
|
|
88
|
+
(f) => !f.error && !f.hasNonZeroScaleFactors && !f.hasNonZeroWordLengths
|
|
89
|
+
).length,
|
|
90
|
+
activeFrames: dump.frames.filter(
|
|
91
|
+
(f) => !f.error && (f.hasNonZeroScaleFactors || f.hasNonZeroWordLengths)
|
|
92
|
+
).length,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return dump
|
|
96
|
+
}
|
package/codec/boundary/pcm.js
CHANGED
|
@@ -26,6 +26,92 @@ function writeFourCc(output, offset, value) {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/** Convert interleaved signed little-endian PCM into normalized channels. */
|
|
30
|
+
export function pcmBytesToChannels(bytes, bitDepth, channelCount, sampleCount) {
|
|
31
|
+
if (![16, 24, 32].includes(bitDepth)) {
|
|
32
|
+
throw new RangeError(`Unsupported PCM bit depth: ${bitDepth}`)
|
|
33
|
+
}
|
|
34
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
35
|
+
const channels = Array.from(
|
|
36
|
+
{ length: channelCount },
|
|
37
|
+
() => new Float32Array(sampleCount)
|
|
38
|
+
)
|
|
39
|
+
let offset = 0
|
|
40
|
+
for (let sample = 0; sample < sampleCount; sample++) {
|
|
41
|
+
for (const channel of channels) {
|
|
42
|
+
let value
|
|
43
|
+
if (bitDepth === 16) value = view.getInt16(offset, true) / 32768
|
|
44
|
+
else if (bitDepth === 32) value = view.getInt32(offset, true) / 2147483648
|
|
45
|
+
else {
|
|
46
|
+
let integer =
|
|
47
|
+
bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16)
|
|
48
|
+
if (integer > 0x7fffff) integer -= 0x1000000
|
|
49
|
+
value = integer / 8388608
|
|
50
|
+
}
|
|
51
|
+
channel[sample] = value
|
|
52
|
+
offset += bitDepth / 8
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return channels
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Read a RIFF/WAVE byte image. Native file I/O stays outside this boundary. */
|
|
59
|
+
export function parsePcmWave(bytes) {
|
|
60
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
|
61
|
+
const fourCc = (offset) =>
|
|
62
|
+
String.fromCharCode(...bytes.subarray(offset, offset + 4))
|
|
63
|
+
if (bytes.length < 12 || fourCc(0) !== 'RIFF' || fourCc(8) !== 'WAVE') {
|
|
64
|
+
throw new Error('Invalid RIFF/WAVE file')
|
|
65
|
+
}
|
|
66
|
+
const end = view.getUint32(4, true) + 8
|
|
67
|
+
if (end > bytes.length || end < 12)
|
|
68
|
+
throw new Error('Truncated RIFF/WAVE file')
|
|
69
|
+
let format = null
|
|
70
|
+
let data = null
|
|
71
|
+
for (let offset = 12; offset + 8 <= end;) {
|
|
72
|
+
const size = view.getUint32(offset + 4, true)
|
|
73
|
+
const start = offset + 8
|
|
74
|
+
if (start + size > end) throw new Error('Truncated WAVE chunk')
|
|
75
|
+
if (fourCc(offset) === 'fmt ') {
|
|
76
|
+
if (size < 16) throw new Error('Invalid WAVE format chunk')
|
|
77
|
+
format = {
|
|
78
|
+
code: view.getUint16(start, true),
|
|
79
|
+
channelCount: view.getUint16(start + 2, true),
|
|
80
|
+
sampleRate: view.getUint32(start + 4, true),
|
|
81
|
+
blockAlign: view.getUint16(start + 12, true),
|
|
82
|
+
bitDepth: view.getUint16(start + 14, true),
|
|
83
|
+
}
|
|
84
|
+
} else if (fourCc(offset) === 'data' && data === null)
|
|
85
|
+
data = bytes.subarray(start, start + size)
|
|
86
|
+
offset = start + size + (size & 1)
|
|
87
|
+
}
|
|
88
|
+
if (!format || data === null)
|
|
89
|
+
throw new Error('WAVE requires fmt and data chunks')
|
|
90
|
+
const { code, channelCount, sampleRate, blockAlign, bitDepth } = format
|
|
91
|
+
if (code !== 1 || ![16, 24, 32].includes(bitDepth)) {
|
|
92
|
+
throw new Error('WAVE input must be signed 16, 24, or 32-bit integer PCM')
|
|
93
|
+
}
|
|
94
|
+
if (channelCount !== 1 && channelCount !== 2)
|
|
95
|
+
throw new Error(`Unsupported channel count: ${channelCount}`)
|
|
96
|
+
if (
|
|
97
|
+
sampleRate < 1 ||
|
|
98
|
+
blockAlign !== (channelCount * bitDepth) / 8 ||
|
|
99
|
+
data.length % blockAlign !== 0
|
|
100
|
+
) {
|
|
101
|
+
throw new Error('Invalid WAVE sample layout')
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
channels: pcmBytesToChannels(
|
|
105
|
+
data,
|
|
106
|
+
bitDepth,
|
|
107
|
+
channelCount,
|
|
108
|
+
data.length / blockAlign
|
|
109
|
+
),
|
|
110
|
+
sampleRate,
|
|
111
|
+
bitDepth,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
29
115
|
/**
|
|
30
116
|
* Join complete PCM frames into one channel.
|
|
31
117
|
*
|
package/codec/boundary/stream.js
CHANGED
|
@@ -72,20 +72,6 @@ function createDummyFrame() {
|
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
/**
|
|
76
|
-
* Collect an async iterable.
|
|
77
|
-
*
|
|
78
|
-
* @param {AsyncIterable} frames
|
|
79
|
-
* @returns {Promise<Array>}
|
|
80
|
-
*/
|
|
81
|
-
async function collect(frames) {
|
|
82
|
-
const output = []
|
|
83
|
-
for await (const frame of frames) {
|
|
84
|
-
output.push(frame)
|
|
85
|
-
}
|
|
86
|
-
return output
|
|
87
|
-
}
|
|
88
|
-
|
|
89
75
|
/**
|
|
90
76
|
* Encode chronological PCM frames.
|
|
91
77
|
*/
|
|
@@ -115,22 +101,21 @@ export class AeaStreamingEncoder {
|
|
|
115
101
|
if (this.finalized) {
|
|
116
102
|
throw new Error('ATRAC1 encoder has already been finalized')
|
|
117
103
|
}
|
|
118
|
-
for await (const frame of frames)
|
|
119
|
-
|
|
120
|
-
if (this.channelCount === 1) {
|
|
121
|
-
yield this.leftEncoder(frame)
|
|
122
|
-
} else {
|
|
123
|
-
const [left, right] = frame
|
|
124
|
-
yield this.leftEncoder(left)
|
|
125
|
-
yield this.rightEncoder(right)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
this.sampleCount += SAMPLES_PER_FRAME
|
|
104
|
+
for await (const frame of frames) yield* this.push(frame)
|
|
105
|
+
}
|
|
129
106
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
107
|
+
/** Encode one chronological frame for either an async or synchronous host. */
|
|
108
|
+
push(frame) {
|
|
109
|
+
if (this.finalized)
|
|
110
|
+
throw new Error('ATRAC1 encoder has already been finalized')
|
|
111
|
+
validateChunk(frame, this.channelCount)
|
|
112
|
+
const encoded =
|
|
113
|
+
this.channelCount === 1
|
|
114
|
+
? [this.leftEncoder(frame)]
|
|
115
|
+
: [this.leftEncoder(frame[0]), this.rightEncoder(frame[1])]
|
|
116
|
+
this.sampleCount += SAMPLES_PER_FRAME
|
|
117
|
+
if (this.onProgress) this.onProgress(this.frameIndex++)
|
|
118
|
+
return encoded
|
|
134
119
|
}
|
|
135
120
|
|
|
136
121
|
/**
|
|
@@ -184,22 +169,29 @@ export class AeaStreamingDecoder {
|
|
|
184
169
|
*/
|
|
185
170
|
async *frames(frames) {
|
|
186
171
|
for await (const frame of frames) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
this.reportProgress()
|
|
190
|
-
} else if (this.leftFrame === null) {
|
|
191
|
-
this.leftFrame = frame
|
|
192
|
-
} else {
|
|
193
|
-
yield this.trim([
|
|
194
|
-
this.leftDecoder(this.leftFrame),
|
|
195
|
-
this.rightDecoder(frame),
|
|
196
|
-
])
|
|
197
|
-
this.leftFrame = null
|
|
198
|
-
this.reportProgress()
|
|
199
|
-
}
|
|
172
|
+
const decoded = this.push(frame)
|
|
173
|
+
if (decoded !== null) yield decoded
|
|
200
174
|
}
|
|
201
175
|
}
|
|
202
176
|
|
|
177
|
+
/** Decode one sound unit, returning null until a stereo pair is complete. */
|
|
178
|
+
push(frame) {
|
|
179
|
+
let decoded
|
|
180
|
+
if (this.channelCount === 1) decoded = this.trim(this.leftDecoder(frame))
|
|
181
|
+
else if (this.leftFrame === null) {
|
|
182
|
+
this.leftFrame = frame
|
|
183
|
+
return null
|
|
184
|
+
} else {
|
|
185
|
+
decoded = this.trim([
|
|
186
|
+
this.leftDecoder(this.leftFrame),
|
|
187
|
+
this.rightDecoder(frame),
|
|
188
|
+
])
|
|
189
|
+
this.leftFrame = null
|
|
190
|
+
}
|
|
191
|
+
this.reportProgress()
|
|
192
|
+
return decoded
|
|
193
|
+
}
|
|
194
|
+
|
|
203
195
|
/**
|
|
204
196
|
* Complete an unmatched stereo pair.
|
|
205
197
|
*
|
|
@@ -294,8 +286,9 @@ export async function encodeAeaPcm(channels, options = {}) {
|
|
|
294
286
|
channelCount: channels.length,
|
|
295
287
|
encoderOptions: new EncoderOptions(encoderValues),
|
|
296
288
|
})
|
|
297
|
-
const
|
|
298
|
-
const
|
|
289
|
+
const encoded = []
|
|
290
|
+
for (const frame of frameBufferToFrames(channels))
|
|
291
|
+
encoded.push(...encoder.push(frame))
|
|
299
292
|
encoded.push(...encoder.finish())
|
|
300
293
|
|
|
301
294
|
const header = AeaFile.createHeader(
|
|
@@ -339,16 +332,17 @@ export async function decodeAeaPcm(input) {
|
|
|
339
332
|
primingSampleCount: info.primingSampleCount,
|
|
340
333
|
sampleCount: info.sampleCount,
|
|
341
334
|
})
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
335
|
+
const decoded = []
|
|
336
|
+
for (
|
|
337
|
+
let offset = AEA_HEADER_SIZE;
|
|
338
|
+
offset + SOUND_UNIT_SIZE <= bytes.length;
|
|
339
|
+
offset += SOUND_UNIT_SIZE
|
|
340
|
+
) {
|
|
341
|
+
const frame = decoder.push(
|
|
342
|
+
deserializeFrame(bytes.subarray(offset, offset + SOUND_UNIT_SIZE))
|
|
343
|
+
)
|
|
344
|
+
if (frame !== null) decoded.push(frame)
|
|
345
|
+
}
|
|
352
346
|
decoded.push(...decoder.finish())
|
|
353
347
|
|
|
354
348
|
if (info.channelCount === 1) {
|