carta1 1.0.12 → 1.0.14

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.
@@ -17,7 +17,11 @@ import {
17
17
  NUM_BFUS,
18
18
  SAMPLE_RATE,
19
19
  BFU_FREQUENCIES,
20
+ BFU_AMOUNTS_COUNT,
20
21
  PSYMODEL_MIN_POWER_DB,
22
+ PSYMODEL_CB_FREQ_INDICES,
23
+ INTERPOLATION_COMPENSATION_FACTOR,
24
+ PSYMODEL_FFT_SIZE,
21
25
  } from '../core/constants.js'
22
26
 
23
27
  /**
@@ -32,10 +36,10 @@ export function allocateBits(psychoResults, bfuData, bfuSizes, maxBfuCount) {
32
36
  // Calculate signal energy for each BFU
33
37
  const signalEnergyDb = calculateBfuEnergy(bfuData, bfuSizes, maxBfuCount)
34
38
 
35
- // Map psychoacoustic masking threshold to BFUs
36
- const maskingThreshold =
37
- psychoResults.maskingThreshold || psychoResults.globalThreshold
38
- const maskThresholdDb = mapThresholdToBfus(maskingThreshold)
39
+ // Interpolate critical band thresholds to BFUs
40
+ const maskThresholdDb = interpolateCriticalBandsToBfus(
41
+ psychoResults.criticalBandThresholds
42
+ )
39
43
 
40
44
  // Calculate Signal-to-Mask Ratios
41
45
  const smrValues = calculateSMR(signalEnergyDb, maskThresholdDb, maxBfuCount)
@@ -90,7 +94,7 @@ function calculateBfuEnergy(bfuData, bfuSizes, bfuCount) {
90
94
  energy += data[j] * data[j]
91
95
  }
92
96
 
93
- // Convert to dB (10 * log10(energy/size))
97
+ // Convert to dB correctly using log10
94
98
  energyPerBfu[i] =
95
99
  energy > 0 ? 10 * Math.log10(energy / size) : PSYMODEL_MIN_POWER_DB
96
100
  }
@@ -99,25 +103,51 @@ function calculateBfuEnergy(bfuData, bfuSizes, bfuCount) {
99
103
  }
100
104
 
101
105
  /**
102
- * Map frequency-domain masking threshold to BFU indices
103
- * @param {Float32Array} globalThreshold - Frequency-domain masking threshold
106
+ * Interpolate critical band thresholds directly to BFU frequencies
107
+ * @param {Float32Array} criticalBandThresholds - Critical band thresholds
104
108
  * @returns {Float32Array} Masking threshold per BFU
105
109
  */
106
- function mapThresholdToBfus(globalThreshold) {
110
+ function interpolateCriticalBandsToBfus(criticalBandThresholds) {
107
111
  const maskThresholdPerBfu = new Float32Array(NUM_BFUS)
108
- maskThresholdPerBfu.fill(PSYMODEL_MIN_POWER_DB)
109
-
110
- // Calculate frequency resolution
111
- const fftSize = (globalThreshold.length - 1) * 2
112
- const freqPerBin = SAMPLE_RATE / fftSize
112
+ const freqPerBin = SAMPLE_RATE / PSYMODEL_FFT_SIZE
113
113
 
114
- // Map each BFU's center frequency to nearest FFT bin
115
114
  for (let i = 0; i < NUM_BFUS; i++) {
116
115
  const bfuFreq = BFU_FREQUENCIES[i]
117
116
  const fftBinIndex = Math.round(bfuFreq / freqPerBin)
118
117
 
119
- if (fftBinIndex >= 0 && fftBinIndex < globalThreshold.length) {
120
- maskThresholdPerBfu[i] = globalThreshold[fftBinIndex]
118
+ // Find which critical bands surround this FFT bin
119
+ let bandIndex = 0
120
+ while (
121
+ bandIndex < PSYMODEL_CB_FREQ_INDICES.length - 1 &&
122
+ PSYMODEL_CB_FREQ_INDICES[bandIndex + 1] < fftBinIndex
123
+ ) {
124
+ bandIndex++
125
+ }
126
+
127
+ if (fftBinIndex <= PSYMODEL_CB_FREQ_INDICES[0]) {
128
+ maskThresholdPerBfu[i] = criticalBandThresholds[0]
129
+ } else if (
130
+ fftBinIndex >=
131
+ PSYMODEL_CB_FREQ_INDICES[PSYMODEL_CB_FREQ_INDICES.length - 1]
132
+ ) {
133
+ maskThresholdPerBfu[i] =
134
+ criticalBandThresholds[criticalBandThresholds.length - 1]
135
+ } else {
136
+ const x0 = PSYMODEL_CB_FREQ_INDICES[bandIndex]
137
+ const x1 = PSYMODEL_CB_FREQ_INDICES[bandIndex + 1]
138
+ const y0 = criticalBandThresholds[bandIndex]
139
+ const y1 = criticalBandThresholds[bandIndex + 1]
140
+ const bandWidth = x1 - x0
141
+
142
+ if (bandWidth > 0) {
143
+ const t = (fftBinIndex - x0) / bandWidth
144
+ const interpolated = y0 + (y1 - y0) * t
145
+ const compensation =
146
+ Math.abs(y1 - y0) * INTERPOLATION_COMPENSATION_FACTOR
147
+ maskThresholdPerBfu[i] = interpolated + compensation
148
+ } else {
149
+ maskThresholdPerBfu[i] = y0
150
+ }
121
151
  }
122
152
  }
123
153
 
@@ -135,7 +165,6 @@ function calculateSMR(signalEnergyDb, maskThresholdDb, maxBfuCount) {
135
165
  const smrValues = new Float32Array(maxBfuCount)
136
166
 
137
167
  for (let bfu = 0; bfu < maxBfuCount; bfu++) {
138
- // BFUs with no signal have undefined SMR
139
168
  smrValues[bfu] =
140
169
  signalEnergyDb[bfu] <= PSYMODEL_MIN_POWER_DB
141
170
  ? -Infinity
@@ -157,111 +186,86 @@ function selectOptimalBfuCount(smrValues, signalEnergyDb, maxBfuCount) {
157
186
  const hasAudibleContent = smrValues.some((smr) => smr > 0)
158
187
 
159
188
  if (hasAudibleContent) {
160
- return selectBfuCountForAudibleContent(smrValues, maxBfuCount)
161
- } else {
162
- return selectBfuCountForInaudibleContent(
163
- smrValues,
164
- signalEnergyDb,
165
- maxBfuCount
166
- )
167
- }
168
- }
169
-
170
- /**
171
- * Select BFU count when content is above masking threshold
172
- * Uses diminishing returns approach - find where adding more BFUs
173
- * provides minimal benefit
174
- */
175
- function selectBfuCountForAudibleContent(smrValues, maxBfuCount) {
176
- // Create sorted list of BFUs by their contribution (positive SMR only)
177
- const contributions = []
178
- for (let i = 0; i < maxBfuCount; i++) {
179
- if (smrValues[i] > 0) {
180
- contributions.push({ index: i, smr: smrValues[i] })
189
+ // Create sorted list of BFUs by their contribution (positive SMR only)
190
+ const contributions = []
191
+ for (let i = 0; i < maxBfuCount; i++) {
192
+ if (smrValues[i] > 0) {
193
+ contributions.push({ index: i, smr: smrValues[i] })
194
+ }
181
195
  }
182
- }
183
- contributions.sort((a, b) => b.smr - a.smr)
184
-
185
- if (contributions.length === 0) {
186
- return BFU_AMOUNTS[BFU_AMOUNTS.length - 1]
187
- }
188
-
189
- // Find the natural cutoff using gradient of cumulative SMR
190
- const cumulativeSMR = []
191
- let sum = 0
192
- for (let i = 0; i < contributions.length; i++) {
193
- sum += contributions[i].smr
194
- cumulativeSMR.push(sum)
195
- }
196
+ contributions.sort((a, b) => b.smr - a.smr)
196
197
 
197
- // Find where the gradient becomes shallow (diminishing returns)
198
- for (const candidateCount of BFU_AMOUNTS) {
199
- if (candidateCount > maxBfuCount) continue
200
-
201
- // Check if all significant contributions fit within this count
202
- const requiredBfus = contributions
203
- .slice(0, Math.min(contributions.length, candidateCount))
204
- .map((c) => c.index)
198
+ if (contributions.length === 0) {
199
+ return BFU_AMOUNTS[BFU_AMOUNTS.length - 1]
200
+ }
205
201
 
206
- const maxRequiredIndex = Math.max(...requiredBfus, 0)
202
+ // Find the natural cutoff using gradient of cumulative SMR
203
+ const cumulativeSMR = []
204
+ let sum = 0
205
+ for (let i = 0; i < contributions.length; i++) {
206
+ sum += contributions[i].smr
207
+ cumulativeSMR.push(sum)
208
+ }
207
209
 
208
- if (maxRequiredIndex < candidateCount) {
209
- // Check if we've captured the bulk of the contribution
210
- const capturedIndex = Math.min(
211
- candidateCount - 1,
212
- contributions.length - 1
213
- )
214
- if (capturedIndex >= contributions.length - 1) {
215
- return candidateCount
216
- }
210
+ // Find where the gradient becomes shallow (diminishing returns)
211
+ for (const candidateCount of BFU_AMOUNTS) {
212
+ if (candidateCount > maxBfuCount) continue
217
213
 
218
- // Use gradient to detect diminishing returns
219
- const currentValue = cumulativeSMR[capturedIndex]
220
- const totalValue = cumulativeSMR[cumulativeSMR.length - 1]
221
- const remainingIndices = contributions.length - capturedIndex - 1
214
+ // Check if all significant contributions fit within this count
215
+ const requiredBfus = contributions
216
+ .slice(0, Math.min(contributions.length, candidateCount))
217
+ .map((c) => c.index)
222
218
 
223
- if (remainingIndices > 0) {
224
- const averageRemaining = (totalValue - currentValue) / remainingIndices
225
- const averageCaptured = currentValue / (capturedIndex + 1)
219
+ const maxRequiredIndex = Math.max(...requiredBfus, 0)
226
220
 
227
- // If remaining BFUs contribute much less on average, we've found our cutoff
228
- if (averageRemaining < averageCaptured * 0.1) {
221
+ if (maxRequiredIndex < candidateCount) {
222
+ // Check if we've captured the bulk of the contribution
223
+ const capturedIndex = Math.min(
224
+ candidateCount - 1,
225
+ contributions.length - 1
226
+ )
227
+ if (capturedIndex >= contributions.length - 1) {
229
228
  return candidateCount
230
229
  }
230
+
231
+ // Use gradient to detect diminishing returns
232
+ const remainingIndices = contributions.length - capturedIndex - 1
233
+ if (remainingIndices > 0) {
234
+ const avgRemaining =
235
+ (cumulativeSMR[cumulativeSMR.length - 1] -
236
+ cumulativeSMR[capturedIndex]) /
237
+ remainingIndices
238
+ const avgCaptured = cumulativeSMR[capturedIndex] / (capturedIndex + 1)
239
+
240
+ // If remaining BFUs contribute much less on average, we've found our cutoff
241
+ if (avgRemaining < avgCaptured * 0.1) {
242
+ return candidateCount
243
+ }
244
+ }
231
245
  }
232
246
  }
233
- }
234
-
235
- return BFU_AMOUNTS[BFU_AMOUNTS.length - 1]
236
- }
237
-
238
- /**
239
- * Select BFU count when all content is below masking threshold
240
- * Simply finds the smallest count that includes all BFUs with signal
241
- */
242
- function selectBfuCountForInaudibleContent(
243
- smrValues,
244
- signalEnergyDb,
245
- maxBfuCount
246
- ) {
247
- // Find the highest BFU index that contains actual signal
248
- let highestSignalBfu = -1
249
-
250
- for (let i = maxBfuCount - 1; i >= 0; i--) {
251
- if (signalEnergyDb[i] > PSYMODEL_MIN_POWER_DB && smrValues[i] > -Infinity) {
252
- highestSignalBfu = i
253
- break
247
+ } else {
248
+ // Find the highest BFU index that contains actual signal
249
+ let highestSignalBfu = -1
250
+ for (let i = maxBfuCount - 1; i >= 0; i--) {
251
+ if (
252
+ signalEnergyDb[i] > PSYMODEL_MIN_POWER_DB &&
253
+ smrValues[i] > -Infinity
254
+ ) {
255
+ highestSignalBfu = i
256
+ break
257
+ }
254
258
  }
255
- }
256
259
 
257
- // Find smallest BFU count that includes all signal
258
- for (const candidateCount of BFU_AMOUNTS) {
259
- if (candidateCount > highestSignalBfu) {
260
- return candidateCount
260
+ // Find smallest BFU count that includes all signal
261
+ for (const candidateCount of BFU_AMOUNTS) {
262
+ if (candidateCount > highestSignalBfu) {
263
+ return candidateCount
264
+ }
261
265
  }
262
266
  }
263
267
 
264
- return BFU_AMOUNTS[BFU_AMOUNTS.length - 1]
268
+ return BFU_AMOUNTS[BFU_AMOUNTS_COUNT - 1]
265
269
  }
266
270
 
267
271
  /**
@@ -278,36 +282,44 @@ function performBitAllocation(
278
282
  bfuSizes,
279
283
  availableBits
280
284
  ) {
281
- const allocation = new Int32Array(actualBfuCount).fill(0)
285
+ const allocation = new Int32Array(actualBfuCount)
286
+ const heap = []
287
+
288
+ // Initialize heap with BFUs that have signal
289
+ for (let i = 0; i < actualBfuCount; i++) {
290
+ if (smrValues[i] > -Infinity) {
291
+ heap.push({ bfu: i, priority: smrValues[i] })
292
+ }
293
+ }
282
294
 
283
- // Initialize priority queue with BFUs that have signal
284
- const priorityQueue = createPriorityQueue(smrValues, actualBfuCount)
295
+ // Build max heap
296
+ for (let i = Math.floor(heap.length / 2) - 1; i >= 0; i--) {
297
+ heapifyDown(heap, i)
298
+ }
285
299
 
286
- // Greedy allocation: always upgrade BFU with highest priority
287
- while (!priorityQueue.isEmpty()) {
288
- const bestBfu = priorityQueue.peek()
300
+ // Greedy allocation
301
+ while (heap.length > 0 && availableBits > 0) {
302
+ const bestBfu = heap[0]
289
303
  const currentWl = allocation[bestBfu.bfu]
290
304
 
291
- // Check if BFU is already at maximum word length
292
305
  if (currentWl >= MAX_WORD_LENGTH_INDEX) {
293
- priorityQueue.pop()
306
+ heap[0] = heap[heap.length - 1]
307
+ heap.pop()
308
+ if (heap.length > 0) heapifyDown(heap, 0)
294
309
  continue
295
310
  }
296
311
 
297
- // Calculate cost to upgrade this BFU
298
312
  const upgradeCost = calculateUpgradeCost(bestBfu.bfu, currentWl, bfuSizes)
299
313
 
300
314
  if (upgradeCost <= availableBits) {
301
- // Perform upgrade
302
315
  availableBits -= upgradeCost
303
316
  allocation[bestBfu.bfu]++
304
-
305
- // Update priority based on distortion reduction
306
317
  bestBfu.priority += DISTORTION_DELTA_DB[currentWl]
307
- priorityQueue.updateTop()
318
+ heapifyDown(heap, 0)
308
319
  } else {
309
- // Not enough bits for this BFU
310
- priorityQueue.pop()
320
+ heap[0] = heap[heap.length - 1]
321
+ heap.pop()
322
+ if (heap.length > 0) heapifyDown(heap, 0)
311
323
  }
312
324
  }
313
325
 
@@ -316,6 +328,10 @@ function performBitAllocation(
316
328
 
317
329
  /**
318
330
  * Calculate bit cost to upgrade a BFU's word length
331
+ * @param {number} bfuIndex - Index of the BFU to upgrade
332
+ * @param {number} currentWl - Current word length of the BFU
333
+ * @param {number[]} bfuSizes - Array of BFU sizes (number of coefficients per BFU)
334
+ * @returns {number} Additional bits required to upgrade word length by 1
319
335
  */
320
336
  function calculateUpgradeCost(bfuIndex, currentWl, bfuSizes) {
321
337
  const currentBits = WORD_LENGTH_BITS[currentWl] * bfuSizes[bfuIndex]
@@ -323,52 +339,21 @@ function calculateUpgradeCost(bfuIndex, currentWl, bfuSizes) {
323
339
  return nextBits - currentBits
324
340
  }
325
341
 
326
- /**
327
- * Create a priority queue for BFU allocation
328
- * @param {Float32Array} smrValues - Initial priorities (SMR values)
329
- * @param {number} bfuCount - Number of BFUs
330
- * @returns {Object} Priority queue interface
331
- */
332
- function createPriorityQueue(smrValues, bfuCount) {
333
- const heap = []
334
-
335
- // Initialize with BFUs that have signal
336
- for (let i = 0; i < bfuCount; i++) {
337
- if (smrValues[i] > -Infinity) {
338
- heap.push({ bfu: i, priority: smrValues[i] })
339
- }
340
- }
341
-
342
- // Build max heap
343
- for (let i = Math.floor(heap.length / 2) - 1; i >= 0; i--) {
344
- heapifyDown(heap, i)
345
- }
346
-
347
- return {
348
- isEmpty: () => heap.length === 0,
349
- peek: () => heap[0],
350
- pop: () => popMax(heap),
351
- updateTop: () => heapifyDown(heap, 0),
352
- }
353
- }
354
-
355
342
  /**
356
343
  * Maintain max heap property by moving element down
357
344
  * @param {Array} heap - Heap array
358
345
  * @param {number} startIdx - Starting index
359
346
  */
360
347
  function heapifyDown(heap, startIdx) {
361
- let idx = startIdx
362
- const element = heap[idx]
363
348
  const length = heap.length
349
+ const element = heap[startIdx]
364
350
 
365
351
  while (true) {
366
- const leftChild = 2 * idx + 1
367
- const rightChild = 2 * idx + 2
368
- let largest = idx
352
+ const leftChild = 2 * startIdx + 1
353
+ const rightChild = 2 * startIdx + 2
354
+ let largest = startIdx
369
355
  let largestPriority = element.priority
370
356
 
371
- // Find largest among parent and children
372
357
  if (leftChild < length && heap[leftChild].priority > largestPriority) {
373
358
  largest = leftChild
374
359
  largestPriority = heap[leftChild].priority
@@ -378,30 +363,11 @@ function heapifyDown(heap, startIdx) {
378
363
  largest = rightChild
379
364
  }
380
365
 
381
- // If parent is largest, we're done
382
- if (largest === idx) break
383
-
384
- // Swap and continue
385
- heap[idx] = heap[largest]
386
- idx = largest
387
- }
388
-
389
- heap[idx] = element
390
- }
391
-
392
- /**
393
- * Remove and return the maximum element from heap
394
- * @param {Array} heap - Heap array
395
- * @returns {Object} Maximum element
396
- */
397
- function popMax(heap) {
398
- const max = heap[0]
399
- const last = heap.pop()
366
+ if (largest === startIdx) break
400
367
 
401
- if (heap.length > 0) {
402
- heap[0] = last
403
- heapifyDown(heap, 0)
368
+ heap[startIdx] = heap[largest]
369
+ startIdx = largest
404
370
  }
405
371
 
406
- return max
372
+ heap[startIdx] = element
407
373
  }
@@ -7,7 +7,6 @@ import {
7
7
  MAX_BFU_SIZE,
8
8
  QMF_DELAY,
9
9
  QMF_HIGH_BAND_DELAY,
10
- PSYMODEL_FFT_SIZE,
11
10
  } from './constants.js'
12
11
 
13
12
  export class BufferPool {
@@ -84,12 +83,6 @@ export class BufferPool {
84
83
  256: new Float32Array(256),
85
84
  }
86
85
 
87
- // Psychoacoustic model buffers
88
- this.psychoAnalysis = {
89
- flags: new Uint8Array(PSYMODEL_FFT_SIZE / 2),
90
- threshold: new Float32Array(PSYMODEL_FFT_SIZE / 2 + 1),
91
- }
92
-
93
86
  // Bit allocation buffers
94
87
  this.bfuData = new Array(NUM_BFUS)
95
88
  for (let i = 0; i < NUM_BFUS; i++) {
@@ -33,6 +33,7 @@ export const SPECS_PER_BFU = new Int32Array([
33
33
  20, 20, 20, 20, 20,
34
34
  ])
35
35
 
36
+ export const BFU_AMOUNTS_COUNT = 8
36
37
  export const BFU_AMOUNTS = new Int32Array([20, 28, 32, 36, 40, 44, 48, 52])
37
38
  export const BFU_BAND_BOUNDARIES = new Int32Array([20, 36, 52])
38
39
 
@@ -116,6 +117,9 @@ export const QMF_ODD = (() => {
116
117
  return odd
117
118
  })()
118
119
 
120
+ // Bit allocation
121
+ export const INTERPOLATION_COMPENSATION_FACTOR = 0.25
122
+
119
123
  // Quantization
120
124
  export const MAX_WORD_LENGTH_INDEX = 15
121
125
  export const WORD_LENGTH_BITS = new Int32Array([
@@ -188,6 +192,53 @@ export const SPREADING_MATRIX = (() => {
188
192
  export const PSYMODEL_MIN_POWER_DB = -200
189
193
  export const PSYMODEL_FFT_SIZE = 2048
190
194
 
195
+ // Pre-computes lookup tables for resampling the MDCT power spectrum into a PSD.
196
+ // For each PSD bin, these tables provide the two source MDCT indices and the
197
+ // linear interpolation weight needed to calculate the final power value.
198
+ export const [
199
+ PSYMODEL_PSD_SOURCE_IDX0,
200
+ PSYMODEL_PSD_SOURCE_IDX1,
201
+ PSYMODEL_PSD_INTERP_WEIGHT,
202
+ ] = (() => {
203
+ const half = PSYMODEL_FFT_SIZE >>> 1
204
+ const sizeMinus1 = PSYMODEL_FFT_SIZE - 1
205
+ const halfDiv2 = half / 2
206
+ const scale = sizeMinus1 / half
207
+
208
+ const idx0 = new Uint16Array(half + 1)
209
+ const idx1 = new Uint16Array(half + 1)
210
+ const w1 = new Float32Array(half + 1)
211
+
212
+ for (let i = 0; i <= half; i++) {
213
+ const src = i * scale
214
+ const base = src | 0
215
+
216
+ const j0 = base
217
+ idx0[i] =
218
+ j0 < half
219
+ ? j0 < halfDiv2
220
+ ? j0
221
+ : half - 1 - (j0 - halfDiv2)
222
+ : sizeMinus1 - (j0 - half)
223
+
224
+ const j1 = base + 1
225
+ if (j1 >= PSYMODEL_FFT_SIZE) {
226
+ idx1[i] = idx0[i]
227
+ } else {
228
+ idx1[i] =
229
+ j1 < half
230
+ ? j1 < halfDiv2
231
+ ? j1
232
+ : half - 1 - (j1 - halfDiv2)
233
+ : sizeMinus1 - (j1 - half)
234
+ }
235
+
236
+ w1[i] = src - base
237
+ }
238
+
239
+ return [idx0, idx1, w1]
240
+ })()
241
+
191
242
  // FFT sizes for transient detection
192
243
  export const FFT_SIZE_LOW = 256
193
244
  export const FFT_SIZE_MID = 256
@@ -31,7 +31,6 @@ import {
31
31
  FFT_SIZE_LOW,
32
32
  FFT_SIZE_MID,
33
33
  FFT_SIZE_HIGH,
34
- PSYMODEL_FFT_SIZE,
35
34
  MDCT_SIZE_SHORT,
36
35
  MDCT_SIZE_MID,
37
36
  MDCT_SIZE_LONG,
@@ -300,12 +299,7 @@ export function quantizationStage(context) {
300
299
  bufferPool.bfuData
301
300
  )
302
301
 
303
- const psychoResults = psychoAnalysis(
304
- coefficients,
305
- PSYMODEL_FFT_SIZE,
306
- bufferPool.psychoAnalysis,
307
- options.normalizationDb
308
- )
302
+ const psychoResults = psychoAnalysis(coefficients, options.normalizationDb)
309
303
  const { bfuCount: selectedBfuCount, allocation } = allocateBits(
310
304
  psychoResults,
311
305
  bfuData,