carta1 1.5.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 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
@@ -202,7 +207,6 @@ implementation used to understand the ATRAC1 format and codec behavior.
202
207
 
203
208
  ISC
204
209
 
205
-
206
210
  ## Experimental Web MiniDisc encoder plugin
207
211
 
208
212
  `npm run build` also emits `dist/carta1.wme`, alongside the three existing
@@ -211,7 +215,7 @@ and their source maps. The npm package includes the same build outputs. Release
211
215
  CDN links are pinned to that release's version.
212
216
 
213
217
  **Carta1 (ATRAC1, experimental)** is an ATRAC1 codec for standard MD SP, not
214
- ATRAC3 LP2/LP4 or ATRAC3PLUS Hi-MD. Current Web MiniDisc API v1 does not support
218
+ ATRAC3 LP2/LP4 or ATRAC3plus Hi-MD. Current Web MiniDisc API v1 does not support
215
219
  software ATRAC1 output: its SP modes request PCM for the recorder to encode in
216
220
  hardware. Installing this plugin does not enable Carta1 recording in the
217
221
  unmodified app. All current API v1 codecs (PCM, MP3, AT3, A3+) are explicitly
@@ -224,10 +228,10 @@ with the usual `init`, `deinit`, `getSupportFor`, `transcode`, and progress hook
224
228
  It adds two **proposed, nonstandard** export codec names, using the host's
225
229
  existing recording-mode names:
226
230
 
227
- | `exportParams.format` | PCM channels | Output |
228
- | --- | --- | --- |
229
- | `{ codec: 'SPS', bitrate: 292 }` | 2 | ATRAC1 stereo SP |
230
- | `{ codec: 'SPM', bitrate: 146 }` | 1 | ATRAC1 mono SP |
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 |
231
235
 
232
236
  Bitrates are nominal kbps; ATRAC1 uses 212-byte sound units per channel per
233
237
  512 samples at 44.1 kHz. The host's built-in FFmpeg decodes/resamples sources
@@ -249,3 +253,6 @@ included here.
249
253
  `npm run test:wme` exercises the built archive and its bundled worker for both
250
254
  proposed formats, comparing the returned raw bytes with `encodeAeaPcm` across
251
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 { AeaFile, validateAeaTitle } from '../codec/boundary/container.js'
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
- if (this.channels === 1) {
345
- const frame = new Float32Array(frameSize)
346
- for (let i = 0; i < frameSize; i++) {
347
- frame[i] = this._sampleToFloat(frameBuffer, i * (this.bitDepth / 8))
348
- }
349
- return frame
350
- } else {
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 stats = fs.statSync(inputFile)
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
- (stats.size - AEA_HEADER_SIZE) / SOUND_UNIT_SIZE
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
- let offset = AEA_HEADER_SIZE
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
- offset += SOUND_UNIT_SIZE
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
+ }
@@ -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
  *
@@ -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
- validateChunk(frame, this.channelCount)
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
- if (this.onProgress) {
131
- this.onProgress(this.frameIndex++)
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
- if (this.channelCount === 1) {
188
- yield this.trim(this.leftDecoder(frame))
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 frames = encoder.frames(frameBufferToFrames(channels))
298
- const encoded = await collect(frames)
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 frames = (async function* () {
343
- for (
344
- let offset = AEA_HEADER_SIZE;
345
- offset + SOUND_UNIT_SIZE <= bytes.length;
346
- offset += SOUND_UNIT_SIZE
347
- ) {
348
- yield deserializeFrame(bytes.subarray(offset, offset + SOUND_UNIT_SIZE))
349
- }
350
- })()
351
- const decoded = await collect(decoder.frames(frames))
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) {