carta1 1.1.10 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/bin/cli.js +8 -4
  2. package/codec/{coding/bitallocation.js → allocation/solve.js} +131 -97
  3. package/codec/allocation/source.js +69 -0
  4. package/codec/allocation/stage.js +38 -0
  5. package/codec/analysis/bands.js +68 -0
  6. package/codec/analysis/{transient.js → blocks.js} +90 -47
  7. package/codec/analysis/spectrum.js +160 -0
  8. package/codec/analysis/stage.js +32 -0
  9. package/codec/{browser/interface.js → boundary/browser.js} +1 -1
  10. package/codec/boundary/container.js +96 -0
  11. package/codec/boundary/pcm.js +193 -0
  12. package/codec/boundary/processor.js +208 -0
  13. package/codec/{io/readers.js → boundary/reader.js} +2 -1
  14. package/codec/boundary/stream.js +361 -0
  15. package/codec/{browser → boundary}/worker.js +45 -42
  16. package/codec/core/constants.js +19 -132
  17. package/codec/core/geometry.js +25 -0
  18. package/codec/core/tables.js +215 -0
  19. package/codec/dequantization.js +54 -0
  20. package/codec/index.js +12 -13
  21. package/codec/pipeline/decoder.js +10 -399
  22. package/codec/pipeline/encoder.js +12 -434
  23. package/codec/quantization/spectrum.js +67 -0
  24. package/codec/quantization/stage.js +49 -0
  25. package/codec/signal/fft.js +68 -0
  26. package/codec/{transforms → signal}/mdct.js +37 -65
  27. package/codec/signal/order.js +16 -0
  28. package/codec/signal/qmf.js +539 -0
  29. package/codec/state.js +112 -0
  30. package/codec/{io → syntax}/bitstream.js +27 -8
  31. package/codec/{io/serialization.js → syntax/frame.js} +5 -101
  32. package/codec/synthesis/bands.js +228 -0
  33. package/codec/synthesis/pcm.js +64 -0
  34. package/codec/synthesis/stage.js +17 -0
  35. package/codec/utils.js +9 -52
  36. package/dist/carta1-worker-interface.min.js.map +1 -1
  37. package/dist/carta1-worker.min.js +1 -1
  38. package/dist/carta1-worker.min.js.map +1 -1
  39. package/dist/carta1.min.js +1 -1
  40. package/dist/carta1.min.js.map +1 -1
  41. package/package.json +1 -1
  42. package/codec/coding/quantization.js +0 -149
  43. package/codec/core/buffers.js +0 -81
  44. package/codec/io/processor.js +0 -671
  45. package/codec/transforms/fft.js +0 -69
  46. package/codec/transforms/qmf.js +0 -105
package/bin/cli.js CHANGED
@@ -19,9 +19,10 @@ import { performance } from 'perf_hooks'
19
19
  import cliProgress from 'cli-progress'
20
20
  import wav from 'wav'
21
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'
22
+ import { AudioProcessor } from '../codec/boundary/processor.js'
23
+ import { AeaReader } from '../codec/boundary/reader.js'
24
+ import { AeaFile } from '../codec/boundary/container.js'
25
+ import { deserializeFrame } from '../codec/syntax/frame.js'
25
26
  import { EncoderOptions } from '../codec/core/options.js'
26
27
  import {
27
28
  SAMPLE_RATE,
@@ -483,6 +484,7 @@ async function encodeFile(inputFile, outputFile, options) {
483
484
  const blob = await AudioProcessor.createAeaBlob(encodedFrames, {
484
485
  title,
485
486
  channelCount: reader.channels,
487
+ sampleCount: reader.totalSamples,
486
488
  })
487
489
 
488
490
  const buffer = await blob.arrayBuffer()
@@ -533,11 +535,13 @@ async function decodeFile(inputFile, outputFile, options) {
533
535
 
534
536
  const decodedFrames = AudioProcessor.decodeStream(reader, {
535
537
  channelCount: metadata.channelCount,
538
+ primingSampleCount: metadata.primingSampleCount,
539
+ sampleCount: metadata.sampleCount,
536
540
  onProgress,
537
541
  })
538
542
 
539
543
  const pcmFrames = await AudioProcessor.collectFrames(decodedFrames)
540
- const wavBlob = AudioProcessor.createWavBlob(
544
+ const wavBlob = AudioProcessor.createPcmWaveBlob(
541
545
  pcmFrames,
542
546
  metadata.channelCount,
543
547
  SAMPLE_RATE
@@ -27,13 +27,15 @@ import {
27
27
  FRAME_OVERHEAD_BITS,
28
28
  BITS_PER_BFU_METADATA,
29
29
  MAX_WORD_LENGTH_INDEX,
30
+ } from '../core/constants.js'
31
+ import {
30
32
  WORD_LENGTH_BITS,
31
33
  INV_POWER_OF_TWO,
32
34
  SCALE_FACTORS,
33
35
  BFU_AMOUNTS,
34
36
  WORD_LENGTH_DELTA_BITS,
35
37
  DISTORTION_DELTA_FACTORS,
36
- } from '../core/constants.js'
38
+ } from '../core/tables.js'
37
39
 
38
40
  const biasedScaleFactorCache = new Map()
39
41
 
@@ -61,84 +63,43 @@ function buildBiasedScaleFactorTable(bias) {
61
63
  }
62
64
 
63
65
  /**
64
- * Allocate bits by finding the optimal BFU count and distribution using RDO.
65
- * This function searches through the valid BFU counts to find the one that
66
- * minimizes the total perceptual distortion.
66
+ * Evaluate one legal active-BFU count.
67
67
  *
68
- * @param {Array<Float32Array>} bfuData
69
- * @param {Int32Array} bfuSizes
68
+ * @param {number} activeBfuCount
70
69
  * @param {number} maxBfuCount
71
- * @param {number} allocationBias
72
- * @returns {{bfuCount:number, allocation:Int32Array, scaleFactorIndices:Int32Array}}
70
+ * @param {Int32Array} bfuSizes
71
+ * @param {number} remainingBits
72
+ * @param {Float64Array} biasedScaleFactors
73
+ * @param {Int32Array} allScaleFactorIndices
74
+ * @param {Float32Array} zeroBitDistortions
75
+ * @returns {object} Allocation result.
73
76
  */
74
- export function allocateBits(bfuData, bfuSizes, maxBfuCount, allocationBias) {
75
- const allScaleFactorIndices = new Int32Array(maxBfuCount)
76
- const zeroBitDistortions = new Float32Array(maxBfuCount)
77
- const biasedScaleFactors = buildBiasedScaleFactorTable(allocationBias)
78
-
79
- for (let i = 0; i < maxBfuCount; i++) {
80
- const sz = bfuSizes[i] | 0
81
- if (sz === 0) continue
82
-
83
- const sfi = findScaleFactor(bfuData[i], sz)
84
- allScaleFactorIndices[i] = sfi
85
- if (sfi > 0) {
86
- const effectiveScaleFactor = biasedScaleFactors[sfi]
87
- zeroBitDistortions[i] = effectiveScaleFactor * 2.0 * sz
88
- }
89
- }
90
-
91
- let bestResult = null
92
- let minTotalDistortion = Infinity
93
-
94
- for (const candidateBfuCount of BFU_AMOUNTS) {
95
- if (candidateBfuCount > maxBfuCount) continue
96
-
97
- const availableBits =
98
- FRAME_BITS -
99
- FRAME_OVERHEAD_BITS -
100
- candidateBfuCount * BITS_PER_BFU_METADATA
101
-
102
- if (availableBits < 0) continue
103
-
104
- const rdoResult = distributeBitsRDO(
105
- candidateBfuCount,
106
- bfuSizes,
107
- availableBits,
108
- biasedScaleFactors,
109
- allScaleFactorIndices
110
- )
111
-
112
- const totalDistortion = calculateTotalDistortion(
113
- candidateBfuCount,
114
- maxBfuCount,
115
- bfuSizes,
116
- rdoResult.wordLengths,
117
- rdoResult.scaleFactorIndices,
118
- biasedScaleFactors,
119
- zeroBitDistortions
120
- )
121
-
122
- if (totalDistortion < minTotalDistortion) {
123
- minTotalDistortion = totalDistortion
124
- bestResult = {
125
- bfuCount: candidateBfuCount,
126
- allocation: rdoResult.wordLengths,
127
- scaleFactorIndices: rdoResult.scaleFactorIndices,
128
- }
129
- }
130
- }
131
-
132
- if (!bestResult) {
133
- const fallbackBfuCount = BFU_AMOUNTS[0]
134
- return {
135
- bfuCount: fallbackBfuCount,
136
- allocation: new Int32Array(fallbackBfuCount),
137
- scaleFactorIndices: new Int32Array(NUM_BFUS),
138
- }
139
- }
140
-
141
- return bestResult
77
+ function search(
78
+ activeBfuCount,
79
+ maxBfuCount,
80
+ bfuSizes,
81
+ remainingBits,
82
+ biasedScaleFactors,
83
+ allScaleFactorIndices,
84
+ zeroBitDistortions
85
+ ) {
86
+ const allocation = distributeBits(
87
+ activeBfuCount,
88
+ bfuSizes,
89
+ remainingBits,
90
+ biasedScaleFactors,
91
+ allScaleFactorIndices
92
+ )
93
+ const distortion = measureDistortion(
94
+ activeBfuCount,
95
+ maxBfuCount,
96
+ bfuSizes,
97
+ allocation.wordLengths,
98
+ allocation.scaleFactorIndices,
99
+ biasedScaleFactors,
100
+ zeroBitDistortions
101
+ )
102
+ return { distortion, ...allocation }
142
103
  }
143
104
 
144
105
  /**
@@ -154,7 +115,7 @@ export function allocateBits(bfuData, bfuSizes, maxBfuCount, allocationBias) {
154
115
  * @param {Float32Array} zeroBitDistortions
155
116
  * @returns {number}
156
117
  */
157
- function calculateTotalDistortion(
118
+ function measureDistortion(
158
119
  activeBfuCount,
159
120
  maxBfuCount,
160
121
  bfuSizes,
@@ -198,9 +159,9 @@ function calculateTotalDistortion(
198
159
  * @param {number} remainingBits
199
160
  * @param {Float64Array} biasedScaleFactors
200
161
  * @param {Int32Array} allScaleFactorIndices
201
- * @returns {{wordLengths: Int32Array, scaleFactorIndices: Int32Array}}
162
+ * @returns {object} Retained allocation decisions.
202
163
  */
203
- function distributeBitsRDO(
164
+ function distributeBits(
204
165
  activeBfuCount,
205
166
  bfuSizes,
206
167
  remainingBits,
@@ -280,24 +241,6 @@ function distributeBitsRDO(
280
241
  return { wordLengths, scaleFactorIndices: allScaleFactorIndices }
281
242
  }
282
243
 
283
- /**
284
- * Find the optimal scale factor index for a set of coefficients.
285
- * Same semantics as the original.
286
- * @param {Float32Array} coefficients
287
- * @param {number} length
288
- * @returns {number}
289
- */
290
- export function findScaleFactor(coefficients, length) {
291
- let maxAmplitude = 0.0
292
- for (let i = 0; i < length; i++) {
293
- const a = Math.abs(coefficients[i])
294
- if (a > maxAmplitude) maxAmplitude = a
295
- }
296
- if (maxAmplitude === 0) return 0
297
- const index = Math.ceil(3 * (Math.log2(maxAmplitude) + 21))
298
- return Math.max(0, Math.min(63, index))
299
- }
300
-
301
244
  /**
302
245
  * Restores max-heap property by sifting an element down from a given position.
303
246
  *
@@ -339,3 +282,94 @@ function siftDown(heapIndices, heapPriorities, startIndex, heapSize) {
339
282
  heapIndices[i] = idxVal
340
283
  heapPriorities[i] = prVal
341
284
  }
285
+
286
+ /**
287
+ * Allocate bits by finding the optimal BFU count and distribution using RDO.
288
+ * This function searches through the valid BFU counts to find the one that
289
+ * minimizes the total perceptual distortion.
290
+ *
291
+ * @param {Array<Float32Array>} bfuData
292
+ * @param {Int32Array} bfuSizes
293
+ * @param {number} maxBfuCount
294
+ * @param {number} allocationBias
295
+ * @returns {{bfuCount:number, allocation:Int32Array, scaleFactorIndices:Int32Array}}
296
+ */
297
+ export function solve(bfuData, bfuSizes, maxBfuCount, allocationBias) {
298
+ const allScaleFactorIndices = new Int32Array(maxBfuCount)
299
+ const zeroBitDistortions = new Float32Array(maxBfuCount)
300
+ const biasedScaleFactors = buildBiasedScaleFactorTable(allocationBias)
301
+
302
+ for (let i = 0; i < maxBfuCount; i++) {
303
+ const sz = bfuSizes[i] | 0
304
+ if (sz === 0) continue
305
+
306
+ const sfi = findScaleFactor(bfuData[i], sz)
307
+ allScaleFactorIndices[i] = sfi
308
+ if (sfi > 0) {
309
+ const effectiveScaleFactor = biasedScaleFactors[sfi]
310
+ zeroBitDistortions[i] = effectiveScaleFactor * 2.0 * sz
311
+ }
312
+ }
313
+
314
+ let bestResult = null
315
+ let minTotalDistortion = Infinity
316
+
317
+ for (const candidateBfuCount of BFU_AMOUNTS) {
318
+ if (candidateBfuCount > maxBfuCount) continue
319
+
320
+ const availableBits =
321
+ FRAME_BITS -
322
+ FRAME_OVERHEAD_BITS -
323
+ candidateBfuCount * BITS_PER_BFU_METADATA
324
+
325
+ if (availableBits < 0) continue
326
+
327
+ const result = search(
328
+ candidateBfuCount,
329
+ maxBfuCount,
330
+ bfuSizes,
331
+ availableBits,
332
+ biasedScaleFactors,
333
+ allScaleFactorIndices,
334
+ zeroBitDistortions
335
+ )
336
+
337
+ if (result.distortion < minTotalDistortion) {
338
+ minTotalDistortion = result.distortion
339
+ bestResult = {
340
+ bfuCount: candidateBfuCount,
341
+ allocation: result.wordLengths,
342
+ scaleFactorIndices: result.scaleFactorIndices,
343
+ }
344
+ }
345
+ }
346
+
347
+ if (!bestResult) {
348
+ const fallbackBfuCount = BFU_AMOUNTS[0]
349
+ return {
350
+ bfuCount: fallbackBfuCount,
351
+ allocation: new Int32Array(fallbackBfuCount),
352
+ scaleFactorIndices: new Int32Array(NUM_BFUS),
353
+ }
354
+ }
355
+
356
+ return bestResult
357
+ }
358
+
359
+ /**
360
+ * Find the optimal scale factor index for a set of coefficients.
361
+ * Same semantics as the original.
362
+ * @param {Float32Array} coefficients
363
+ * @param {number} length
364
+ * @returns {number}
365
+ */
366
+ export function findScaleFactor(coefficients, length) {
367
+ let maxAmplitude = 0.0
368
+ for (let i = 0; i < length; i++) {
369
+ const a = Math.abs(coefficients[i])
370
+ if (a > maxAmplitude) maxAmplitude = a
371
+ }
372
+ if (maxAmplitude === 0) return 0
373
+ const index = Math.ceil(3 * (Math.log2(maxAmplitude) + 21))
374
+ return Math.max(0, Math.min(63, index))
375
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Carta1 allocation-source preparation.
3
+ */
4
+
5
+ import {
6
+ BFU_BAND_BOUNDARIES,
7
+ BFU_START_LONG,
8
+ BFU_START_SHORT,
9
+ SPECS_PER_BFU,
10
+ } from '../core/tables.js'
11
+
12
+ /**
13
+ * Group spectral coefficients into block floating units.
14
+ *
15
+ * @param {Float32Array} coefficients
16
+ * @param {Array<number>} blockModes
17
+ * @returns {object} Grouped bit-frequency units.
18
+ */
19
+ export function groupIntoBFUs(coefficients, blockModes) {
20
+ const bfuData = []
21
+ const bfuSizes = []
22
+ let coeffIndex = 0
23
+ let bfuIndex = 0
24
+
25
+ for (let band = 0; band < 3; band++) {
26
+ const bandStart = coeffIndex
27
+ const bandSize = band === 2 ? 256 : 128
28
+ const bandEnd = band < 2 ? BFU_BAND_BOUNDARIES[band] : SPECS_PER_BFU.length
29
+ const startPositions =
30
+ blockModes[band] === 0 ? BFU_START_LONG : BFU_START_SHORT
31
+
32
+ while (bfuIndex < bandEnd) {
33
+ const size = SPECS_PER_BFU[bfuIndex]
34
+ const startPos = startPositions[bfuIndex] - bandStart
35
+ const endPos = startPos + size
36
+ const bfu = new Float32Array(size)
37
+
38
+ if (startPos >= 0 && endPos <= bandSize) {
39
+ bfu.set(coefficients.subarray(bandStart + startPos, bandStart + endPos))
40
+ } else if (startPos < bandSize && endPos > 0) {
41
+ const srcStart = Math.max(0, startPos)
42
+ const srcEnd = Math.min(bandSize, endPos)
43
+ bfu.set(
44
+ coefficients.subarray(bandStart + srcStart, bandStart + srcEnd),
45
+ Math.max(0, -startPos)
46
+ )
47
+ }
48
+
49
+ bfuData.push(bfu)
50
+ bfuSizes.push(size)
51
+ bfuIndex++
52
+ }
53
+
54
+ coeffIndex += bandSize
55
+ }
56
+
57
+ return { bfuData, bfuSizes, bfuCount: bfuIndex }
58
+ }
59
+
60
+ /**
61
+ * Prepare the spectrum as an allocation source.
62
+ *
63
+ * @param {Float32Array} coefficients
64
+ * @param {Array<number>} blockModes
65
+ * @returns {object} Allocation source.
66
+ */
67
+ export function prepare(coefficients, blockModes) {
68
+ return groupIntoBFUs(coefficients, blockModes)
69
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Carta1 allocation stage.
3
+ */
4
+
5
+ import { throwError } from '../utils.js'
6
+ import { prepare } from './source.js'
7
+ import { solve } from './solve.js'
8
+
9
+ /**
10
+ * Allocate the frame budget across block floating units.
11
+ *
12
+ * @param {object} context
13
+ * @returns {Function}
14
+ */
15
+ export function allocate(context) {
16
+ const options =
17
+ context?.options ?? throwError('allocate: options is required')
18
+
19
+ /**
20
+ * @param {object} input
21
+ * @returns {object}
22
+ */
23
+ return (input) => {
24
+ const { coefficients, blockModes } = input
25
+ const { bfuData, bfuSizes, bfuCount } = prepare(coefficients, blockModes)
26
+ const result = solve(bfuData, bfuSizes, bfuCount, options.allocationBias)
27
+ const nBfu = result.bfuCount
28
+
29
+ return {
30
+ bfuData,
31
+ bfuSizes,
32
+ nBfu,
33
+ scaleFactorIndices: result.scaleFactorIndices.slice(0, nBfu),
34
+ wordLengthIndices: result.allocation.slice(0, nBfu),
35
+ blockModes,
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Carta1 QMF analysis application.
3
+ */
4
+
5
+ import { QMF_ANALYSIS_DELAY_SAMPLES } from '../core/constants.js'
6
+ import { analyzeQmf } from '../signal/qmf.js'
7
+ import { throwError } from '../utils.js'
8
+
9
+ /**
10
+ * Split one sample row through a two-band configurable QMF stage.
11
+ *
12
+ * @param {Float32Array} input
13
+ * @param {Float32Array} delay
14
+ * @param {object} scratch
15
+ * @returns {Float32Array[]}
16
+ */
17
+ function analyzePair(input, delay, scratch) {
18
+ const window =
19
+ scratch.analysisWindows[input.length] ??
20
+ throwError(`QMF analysis window for ${input.length} samples is required`)
21
+ window.set(delay)
22
+ window.set(input, delay.length)
23
+
24
+ const sampleCount = input.length / 2
25
+ const bands = [new Float32Array(sampleCount), new Float32Array(sampleCount)]
26
+ analyzeQmf(window, bands, scratch)
27
+ delay.set(
28
+ window.subarray(input.length, input.length + QMF_ANALYSIS_DELAY_SAMPLES)
29
+ )
30
+ return bands
31
+ }
32
+
33
+ /**
34
+ * Split PCM into the three ATRAC1 frequency bands.
35
+ *
36
+ * @param {object} context
37
+ * @returns {Function}
38
+ * @throws {Error} If the buffer pool is missing
39
+ */
40
+ export function analyzeBands(context) {
41
+ const bufferPool =
42
+ context?.bufferPool ?? throwError('analyzeBands: bufferPool is required')
43
+ const delays = bufferPool.qmfAnalysisDelays
44
+ const scratch = bufferPool.qmfScratch
45
+
46
+ /**
47
+ * @param {Float32Array} pcmSamples
48
+ * @returns {object}
49
+ */
50
+ return (pcmSamples) => {
51
+ const stage1 = analyzePair(pcmSamples, delays.lowBand, scratch)
52
+ const stage2 = analyzePair(stage1[0], delays.midBand, scratch)
53
+
54
+ const delayedHigh = scratch.highBandWindows[stage1[1].length]
55
+ delayedHigh.set(bufferPool.qmfHighBandDelay)
56
+ delayedHigh.set(stage1[1], bufferPool.qmfHighBandDelay.length)
57
+
58
+ const highBand = delayedHigh.slice(0, stage1[1].length)
59
+ bufferPool.qmfHighBandDelay.set(
60
+ delayedHigh.subarray(
61
+ stage1[1].length,
62
+ stage1[1].length + bufferPool.qmfHighBandDelay.length
63
+ )
64
+ )
65
+
66
+ return { bands: [stage2[0], stage2[1], highBand] }
67
+ }
68
+ }
@@ -6,53 +6,9 @@
6
6
  * characteristics that require special handling during encoding.
7
7
  */
8
8
 
9
- import { FFT } from '../transforms/fft.js'
10
-
11
- /**
12
- * Perform FFT on time-domain samples and return magnitude spectrum
13
- * @param {Float32Array} samples - Time-domain samples
14
- * @param {number} fftSize - FFT size (must be power of 2)
15
- * @returns {Float32Array} Magnitude spectrum (positive frequencies only)
16
- */
17
- export function performFFT(samples, fftSize) {
18
- const real = new Float32Array(fftSize)
19
- const imag = new Float32Array(fftSize)
20
-
21
- // Copy samples with zero-padding if necessary
22
- const copyLen = Math.min(samples.length, fftSize)
23
- real.set(samples.subarray(0, copyLen))
24
-
25
- // Perform FFT
26
- FFT.fft(real, imag)
27
-
28
- // Calculate magnitude spectrum for positive frequencies
29
- const magnitude = new Float32Array(fftSize / 2)
30
- for (let i = 0; i < fftSize / 2; i++) {
31
- magnitude[i] = Math.sqrt(real[i] * real[i] + imag[i] * imag[i])
32
- }
33
-
34
- return magnitude
35
- }
36
-
37
- /**
38
- * Detect transients by analyzing spectral changes between frames
39
- * @param {Float32Array} currentCoeffs - Current frame coefficients
40
- * @param {Float32Array} prevCoeffs - Previous frame coefficients
41
- * @param {number} threshold - Detection threshold
42
- * @returns {boolean} True if transient detected
43
- */
44
- export function detectTransient(currentCoeffs, prevCoeffs, threshold) {
45
- // Can't detect transients without previous frame
46
- if (!prevCoeffs) return false
47
-
48
- // Calculate all spectral features
49
- const features = calculateSpectralFeatures(currentCoeffs, prevCoeffs)
50
-
51
- // Combine features using perceptually-motivated weighting
52
- const transientScore = calculateTransientScore(features)
53
-
54
- return transientScore > threshold
55
- }
9
+ import { FFT_SIZE_HIGH, FFT_SIZE_LOW, FFT_SIZE_MID } from '../core/constants.js'
10
+ import { FFT } from '../signal/fft.js'
11
+ import { throwError } from '../utils.js'
56
12
 
57
13
  /**
58
14
  * Calculate all spectral features for transient detection
@@ -224,3 +180,90 @@ function calculateTransientScore(features) {
224
180
  4
225
181
  )
226
182
  }
183
+
184
+ /**
185
+ * Perform FFT on time-domain samples and return magnitude spectrum
186
+ * @param {Float32Array} samples - Time-domain samples
187
+ * @param {number} fftSize - FFT size (must be power of 2)
188
+ * @returns {Float32Array} Magnitude spectrum (positive frequencies only)
189
+ */
190
+ export function performFFT(samples, fftSize) {
191
+ const real = new Float32Array(fftSize)
192
+ const imag = new Float32Array(fftSize)
193
+
194
+ // Copy samples with zero-padding if necessary
195
+ const copyLen = Math.min(samples.length, fftSize)
196
+ real.set(samples.subarray(0, copyLen))
197
+
198
+ // Perform FFT
199
+ FFT.transform(real, imag)
200
+
201
+ // Calculate magnitude spectrum for positive frequencies
202
+ const magnitude = new Float32Array(fftSize / 2)
203
+ for (let i = 0; i < fftSize / 2; i++) {
204
+ magnitude[i] = Math.sqrt(real[i] * real[i] + imag[i] * imag[i])
205
+ }
206
+
207
+ return magnitude
208
+ }
209
+
210
+ /**
211
+ * Detect transients by analyzing spectral changes between frames
212
+ * @param {Float32Array} currentCoeffs - Current frame coefficients
213
+ * @param {Float32Array} prevCoeffs - Previous frame coefficients
214
+ * @param {number} threshold - Detection threshold
215
+ * @returns {boolean} True if transient detected
216
+ */
217
+ export function detectTransient(currentCoeffs, prevCoeffs, threshold) {
218
+ // Can't detect transients without previous frame
219
+ if (!prevCoeffs) return false
220
+
221
+ // Calculate all spectral features
222
+ const features = calculateSpectralFeatures(currentCoeffs, prevCoeffs)
223
+
224
+ // Combine features using perceptually-motivated weighting
225
+ const transientScore = calculateTransientScore(features)
226
+
227
+ return transientScore > threshold
228
+ }
229
+
230
+ /**
231
+ * Select transform block sizes from transient analysis.
232
+ *
233
+ * @param {object} context
234
+ * @returns {Function}
235
+ * @throws {Error} If the required context is missing
236
+ */
237
+ export function selectBlocks(context) {
238
+ const bufferPool =
239
+ context?.bufferPool ?? throwError('selectBlocks: bufferPool is required')
240
+ const options =
241
+ context?.options ?? throwError('selectBlocks: options is required')
242
+ const fftSizes = [FFT_SIZE_LOW, FFT_SIZE_MID, FFT_SIZE_HIGH]
243
+
244
+ /**
245
+ * @param {object} input
246
+ * @returns {object}
247
+ */
248
+ return (input) => {
249
+ const { bands } = input
250
+ let blockModes
251
+
252
+ if (options.fixedBlockModes) {
253
+ blockModes = options.fixedBlockModes
254
+ } else {
255
+ blockModes = bands.map((bandSamples, bandIndex) => {
256
+ const coeffs = performFFT(bandSamples, fftSizes[bandIndex])
257
+ const transient = detectTransient(
258
+ coeffs,
259
+ bufferPool.transientDetection[bandIndex],
260
+ options.transientThresholdLow
261
+ )
262
+ bufferPool.transientDetection[bandIndex] = coeffs
263
+ return transient * Math.max(bandIndex + 1, 2)
264
+ })
265
+ }
266
+
267
+ return { bands, blockModes }
268
+ }
269
+ }