carta1 1.0.13 → 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,6 +17,9 @@ import {
17
17
  PSYMODEL_CRITICAL_BANDS,
18
18
  PSYMODEL_THRESHOLD_TABLE,
19
19
  PSYMODEL_FREQ_TO_CB_MAP,
20
+ PSYMODEL_PSD_SOURCE_IDX0,
21
+ PSYMODEL_PSD_INTERP_WEIGHT,
22
+ PSYMODEL_PSD_SOURCE_IDX1,
20
23
  } from '../core/constants.js'
21
24
 
22
25
  /**
@@ -48,11 +51,8 @@ export function fromDb(db) {
48
51
  export function psychoAnalysis(mdctCoeffs, normalizationDb) {
49
52
  const fftScale = PSYMODEL_FFT_SIZE / 512
50
53
 
51
- // Apply frequency band reversal correction
52
- const correctedCoeffs = applyMdctCorrection(mdctCoeffs)
53
-
54
54
  // Calculate power spectral density
55
- const psd = calculatePSDFromMDCT(correctedCoeffs, normalizationDb)
55
+ const psd = calculatePSDFromMDCT(mdctCoeffs, normalizationDb)
56
56
 
57
57
  // Find tonal and non-tonal maskers
58
58
  const { flags, tonalMaskers, nonTonalMaskers } = findAllMaskers(psd, fftScale)
@@ -67,83 +67,48 @@ export function psychoAnalysis(mdctCoeffs, normalizationDb) {
67
67
  return {
68
68
  psd,
69
69
  tonalMaskers,
70
+ tonalMaskersCount: tonalMaskers.length,
70
71
  nonTonalMaskers,
72
+ nonTonalMaskersCount: nonTonalMaskers.length,
71
73
  criticalBandThresholds,
72
74
  flags,
73
75
  }
74
76
  }
75
77
 
76
- /**
77
- * Apply frequency band reversal correction to MDCT coefficients
78
- * @param {Float32Array} mdctCoeffs - Original MDCT coefficients
79
- * @returns {Float32Array} Corrected coefficients
80
- */
81
- function applyMdctCorrection(mdctCoeffs) {
82
- const corrected = new Float32Array(512)
83
-
84
- // Low band: direct copy
85
- corrected.set(mdctCoeffs.subarray(0, 128), 0)
86
-
87
- // Mid band: reversed
88
- for (let i = 0; i < 128; i++) {
89
- corrected[128 + i] = mdctCoeffs[255 - i]
90
- }
91
-
92
- // High band: reversed
93
- for (let i = 0; i < 256; i++) {
94
- corrected[256 + i] = mdctCoeffs[511 - i]
95
- }
96
-
97
- return corrected
98
- }
99
-
100
78
  /**
101
79
  * Calculate Power Spectral Density from MDCT coefficients
102
80
  * @param {Float32Array} mdctCoeffs - MDCT coefficients
103
81
  * @returns {Float32Array} Power spectral density in dB
104
82
  */
105
83
  function calculatePSDFromMDCT(mdctCoeffs, normalizationDb) {
106
- const halfTargetSize = PSYMODEL_FFT_SIZE / 2
107
- const psd = new Float32Array(halfTargetSize + 1)
108
-
109
- // Calculate power values and find maximum
110
- const powers = new Float32Array(512)
111
- let maxPower = 0
112
-
113
- for (let i = 0; i < 512; i++) {
114
- const power = mdctCoeffs[i] * mdctCoeffs[i]
115
- powers[i] = power
116
- maxPower = Math.max(maxPower, power)
117
- }
118
-
119
- const normFactor = 1 / (maxPower || 1)
120
- let maxPsdValue = -Infinity
84
+ const half = PSYMODEL_FFT_SIZE >>> 1
85
+ const psd = new Float32Array(half + 1)
86
+ const linearPower = new Float32Array(half + 1)
121
87
 
122
- // Interpolate for different sizes
123
- const scaleFactor = 512 / PSYMODEL_FFT_SIZE
124
-
125
- for (let i = 0; i <= halfTargetSize; i++) {
126
- const srcIdx = i * scaleFactor
127
- const srcIdxInt = Math.floor(srcIdx)
128
- const frac = srcIdx - srcIdxInt
88
+ let maxLinearPower = 0
89
+ for (let i = 0; i <= half; i++) {
90
+ const a = mdctCoeffs[PSYMODEL_PSD_SOURCE_IDX0[i]]
91
+ const b = mdctCoeffs[PSYMODEL_PSD_SOURCE_IDX1[i]]
129
92
 
130
93
  const power =
131
- srcIdxInt < 511
132
- ? powers[srcIdxInt] * (1 - frac) + powers[srcIdxInt + 1] * frac
133
- : powers[511]
134
-
135
- const normalizedPower = power * normFactor
136
- psd[i] = normalizedPower > 0 ? toDb(normalizedPower) : PSYMODEL_MIN_POWER_DB
94
+ a * a * (1 - PSYMODEL_PSD_INTERP_WEIGHT[i]) +
95
+ b * b * PSYMODEL_PSD_INTERP_WEIGHT[i]
96
+ linearPower[i] = power
137
97
 
138
- maxPsdValue = Math.max(maxPsdValue, psd[i])
98
+ if (power > maxLinearPower) {
99
+ maxLinearPower = power
100
+ }
139
101
  }
140
102
 
141
- // Apply normalization to target maximum
142
- const normalizationDelta = normalizationDb - maxPsdValue
103
+ const offset =
104
+ normalizationDb - PSYMODEL_LOG10_FACTOR * Math.log(maxLinearPower || 1)
143
105
 
144
- for (let i = 0; i < psd.length; i++) {
145
- if (psd[i] > PSYMODEL_MIN_POWER_DB) {
146
- psd[i] += normalizationDelta
106
+ for (let i = 0; i <= half; i++) {
107
+ const power = linearPower[i]
108
+ if (power > 0) {
109
+ psd[i] = PSYMODEL_LOG10_FACTOR * Math.log(power) + offset
110
+ } else {
111
+ psd[i] = PSYMODEL_MIN_POWER_DB
147
112
  }
148
113
  }
149
114
 
@@ -162,10 +127,10 @@ function findAllMaskers(psd, fftScale) {
162
127
  flags.fill(PSYMODEL_NOT_EXAMINED)
163
128
 
164
129
  // Find tonal maskers first
165
- let { tonalList } = findTonalMaskers(psd, flags, fftScale)
130
+ const tonalList = findTonalMaskers(psd, flags, fftScale)
166
131
 
167
132
  // Find non-tonal maskers
168
- let { nonTonalList } = findNonTonalMaskers(flags, psd)
133
+ const nonTonalList = findNonTonalMaskers(flags, psd)
169
134
 
170
135
  // Apply decimation to remove weak maskers
171
136
  const decimated = decimateMaskers(flags, tonalList, nonTonalList)
@@ -187,68 +152,67 @@ function findAllMaskers(psd, fftScale) {
187
152
  function findTonalMaskers(psd, flags, fftScale) {
188
153
  const tonalList = []
189
154
  const maxTonalBin = Math.floor(249 * fftScale)
190
- const fftSize = (psd.length - 1) * 2
191
155
 
192
156
  // Frequency range boundaries
193
157
  const RANGE_LOW = Math.floor(63 * fftScale)
194
158
  const RANGE_MID = Math.floor(127 * fftScale)
195
159
  const RANGE_HIGH = Math.floor(150 * fftScale)
196
160
 
197
- for (let k = 1; k < fftSize / 2 - 1 && k <= maxTonalBin; k++) {
161
+ for (let k = 1; k < psd.length - 1 && k <= maxTonalBin; k++) {
198
162
  // Check for local maximum
199
163
  if (psd[k] <= psd[k - 1] || psd[k] < psd[k + 1]) continue
200
164
 
201
165
  // Determine search range based on frequency
202
- const searchOffsets = getSearchOffsets(k, RANGE_LOW, RANGE_MID, RANGE_HIGH)
203
- if (!searchOffsets) continue
166
+ let searchOffsets
167
+ if (k > 2 && k < RANGE_LOW) {
168
+ searchOffsets = [-2, 2]
169
+ } else if (k >= RANGE_LOW && k < RANGE_MID) {
170
+ searchOffsets = [-3, -2, 2, 3]
171
+ } else if (k >= RANGE_MID && k < RANGE_HIGH) {
172
+ searchOffsets = [-6, -5, -4, -3, -2, 2, 3, 4, 5, 6]
173
+ } else {
174
+ continue
175
+ }
204
176
 
205
177
  // Check tonality criteria
206
178
  if (isTonal(psd, k, searchOffsets)) {
207
179
  // Calculate combined SPL
208
180
  const spl = toDb(fromDb(psd[k - 1]) + fromDb(psd[k]) + fromDb(psd[k + 1]))
209
-
210
181
  tonalList.push({ index: k, spl })
211
182
  markTonalComponent(flags, k, searchOffsets)
212
183
  }
213
184
  }
214
185
 
215
- return { flags, tonalList }
216
- }
217
-
218
- /**
219
- * Get search offsets for tonality check based on frequency
220
- */
221
- function getSearchOffsets(k, rangeLow, rangeMid, rangeHigh) {
222
- if (k > 2 && k < rangeLow) return [-2, 2]
223
- if (k >= rangeLow && k < rangeMid) return [-3, -2, 2, 3]
224
- if (k >= rangeMid && k < rangeHigh) return [-6, -5, -4, -3, -2, 2, 3, 4, 5, 6]
225
- return null
186
+ return tonalList
226
187
  }
227
188
 
228
189
  /**
229
190
  * Check if a peak is tonal based on surrounding bins
191
+ * @param {Float32Array} psd - Power spectral density array
192
+ * @param {number} k - Index of the peak to test
193
+ * @param {number[]} offsets - Array of offset indices to check around the peak
194
+ * @returns {boolean} True if the peak qualifies as tonal
230
195
  */
231
196
  function isTonal(psd, k, offsets) {
232
- const TONAL_THRESHOLD = 7 // dB
233
-
197
+ const TONAL_THRESHOLD = 7.0
234
198
  for (const offset of offsets) {
235
199
  const idx = k + offset
236
- if (idx >= 0 && idx < psd.length) {
237
- if (psd[k] - psd[idx] < TONAL_THRESHOLD) return false
200
+ if (idx >= 0 && idx < psd.length && psd[k] - psd[idx] < TONAL_THRESHOLD) {
201
+ return false
238
202
  }
239
203
  }
240
-
241
204
  return true
242
205
  }
243
206
 
244
207
  /**
245
208
  * Mark component and neighbors as tonal/irrelevant
209
+ * @param {Uint8Array} flags - Component classification flags array
210
+ * @param {number} k - Index of the tonal component
211
+ * @param {number[]} searchOffsets - Array of offset indices used for tonal detection
246
212
  */
247
213
  function markTonalComponent(flags, k, searchOffsets) {
248
214
  flags[k] = PSYMODEL_TONAL
249
-
250
- const allOffsets = [...searchOffsets, -1, 1]
251
- for (const offset of allOffsets) {
215
+ for (const offset of [...searchOffsets, -1, 1]) {
252
216
  const idx = k + offset
253
217
  if (idx >= 0 && idx < flags.length && idx !== k) {
254
218
  flags[idx] = PSYMODEL_IRRELEVANT
@@ -270,7 +234,6 @@ function findNonTonalMaskers(flags, psd) {
270
234
  const cbEnd =
271
235
  PSYMODEL_THRESHOLD_TABLE[PSYMODEL_CRITICAL_BANDS[i + 1]][0] - 1
272
236
 
273
- // Calculate band energy and centroid
274
237
  const bandAnalysis = analyzeCriticalBand(
275
238
  psd,
276
239
  flags,
@@ -294,11 +257,17 @@ function findNonTonalMaskers(flags, psd) {
294
257
  }
295
258
  }
296
259
 
297
- return { flags, nonTonalList }
260
+ return nonTonalList
298
261
  }
299
262
 
300
263
  /**
301
264
  * Analyze a critical band for non-tonal masking
265
+ * @param {Float32Array} psd - Power spectral density array
266
+ * @param {Uint8Array} flags - Component classification flags array
267
+ * @param {number} startBin - Starting frequency bin of the critical band
268
+ * @param {number} endBin - Ending frequency bin of the critical band
269
+ * @param {number} bandIndex - Critical band index for threshold table lookup
270
+ * @returns {Object} Analysis results with power and centroid offset
302
271
  */
303
272
  function analyzeCriticalBand(psd, flags, startBin, endBin, bandIndex) {
304
273
  let totalPower = 0
@@ -310,9 +279,11 @@ function analyzeCriticalBand(psd, flags, startBin, endBin, bandIndex) {
310
279
  const power = fromDb(psd[k])
311
280
  totalPower += power
312
281
 
313
- const barkDiff =
314
- PSYMODEL_THRESHOLD_TABLE[PSYMODEL_FREQ_TO_CB_MAP[k]][1] - baseBark
315
- weightedPower += power * barkDiff
282
+ if (k < PSYMODEL_FREQ_TO_CB_MAP.length) {
283
+ const barkDiff =
284
+ PSYMODEL_THRESHOLD_TABLE[PSYMODEL_FREQ_TO_CB_MAP[k]][1] - baseBark
285
+ weightedPower += power * barkDiff
286
+ }
316
287
 
317
288
  flags[k] = PSYMODEL_IRRELEVANT
318
289
  }
@@ -385,6 +356,33 @@ function calculateCriticalBandThresholds(psd, tonalMaskers, nonTonalMaskers) {
385
356
  PSYMODEL_CRITICAL_BANDS.length
386
357
  )
387
358
 
359
+ // Pre-collect all maskers with their properties
360
+ const maskers = []
361
+
362
+ for (const masker of tonalMaskers) {
363
+ const j = masker.index
364
+ if (j < PSYMODEL_FREQ_TO_CB_MAP.length) {
365
+ maskers.push({
366
+ spl: masker.spl,
367
+ psd: psd[j],
368
+ bark: PSYMODEL_THRESHOLD_TABLE[PSYMODEL_FREQ_TO_CB_MAP[j]][1],
369
+ isTonal: true,
370
+ })
371
+ }
372
+ }
373
+
374
+ for (const masker of nonTonalMaskers) {
375
+ const j = masker.index
376
+ if (j < PSYMODEL_FREQ_TO_CB_MAP.length) {
377
+ maskers.push({
378
+ spl: masker.spl,
379
+ psd: psd[j],
380
+ bark: PSYMODEL_THRESHOLD_TABLE[PSYMODEL_FREQ_TO_CB_MAP[j]][1],
381
+ isTonal: false,
382
+ })
383
+ }
384
+ }
385
+
388
386
  // Calculate threshold for each critical band
389
387
  for (let i = 0; i < PSYMODEL_CRITICAL_BANDS.length; i++) {
390
388
  const bandData = PSYMODEL_THRESHOLD_TABLE[PSYMODEL_CRITICAL_BANDS[i]]
@@ -395,19 +393,21 @@ function calculateCriticalBandThresholds(psd, tonalMaskers, nonTonalMaskers) {
395
393
  let summedEnergy = fromDb(quietThreshold)
396
394
 
397
395
  // Add contributions from all maskers
398
- summedEnergy += calculateMaskerContributions(
399
- psd,
400
- tonalMaskers,
401
- maskedBark,
402
- true // isTonal
403
- )
404
-
405
- summedEnergy += calculateMaskerContributions(
406
- psd,
407
- nonTonalMaskers,
408
- maskedBark,
409
- false // isTonal
410
- )
396
+ for (const masker of maskers) {
397
+ const barkDistance = maskedBark - masker.bark
398
+
399
+ // Check if within masking range
400
+ if (barkDistance >= -3 && barkDistance < 8) {
401
+ const maskedDb = calculateMaskingLevel(
402
+ masker.spl,
403
+ masker.psd,
404
+ masker.bark,
405
+ barkDistance,
406
+ masker.isTonal
407
+ )
408
+ summedEnergy += fromDb(maskedDb)
409
+ }
410
+ }
411
411
 
412
412
  criticalBandThresholds[i] = toDb(summedEnergy)
413
413
  }
@@ -417,37 +417,12 @@ function calculateCriticalBandThresholds(psd, tonalMaskers, nonTonalMaskers) {
417
417
 
418
418
  /**
419
419
  * Calculate energy contributions from a set of maskers
420
- */
421
- function calculateMaskerContributions(psd, maskers, maskedBark, isTonal) {
422
- let totalEnergy = 0
423
-
424
- for (const masker of maskers) {
425
- const j = masker.index
426
- if (j >= PSYMODEL_FREQ_TO_CB_MAP.length) continue
427
-
428
- const maskerBark = PSYMODEL_THRESHOLD_TABLE[PSYMODEL_FREQ_TO_CB_MAP[j]][1]
429
- const barkDistance = maskedBark - maskerBark
430
-
431
- // Check if within masking range
432
- if (barkDistance < -3 || barkDistance >= 8) continue
433
-
434
- // Calculate masking level
435
- const maskedDb = calculateMaskingLevel(
436
- masker.spl,
437
- psd[j],
438
- maskerBark,
439
- barkDistance,
440
- isTonal
441
- )
442
-
443
- totalEnergy += fromDb(maskedDb)
444
- }
445
-
446
- return totalEnergy
447
- }
448
-
449
- /**
450
- * Calculate masking level based on masker properties
420
+ * @param {number} maskerSpl - Sound pressure level of the masker in dB
421
+ * @param {number} maskerPsd - Power spectral density of the masker in dB
422
+ * @param {number} maskerBark - Bark frequency of the masker
423
+ * @param {number} barkDistance - Distance in Bark scale from masker to maskee
424
+ * @param {boolean} isTonal - Whether the masker is tonal or non-tonal
425
+ * @returns {number} Masking threshold contribution in dB
451
426
  */
452
427
  function calculateMaskingLevel(
453
428
  maskerSpl,
@@ -456,25 +431,22 @@ function calculateMaskingLevel(
456
431
  barkDistance,
457
432
  isTonal
458
433
  ) {
459
- // Masking index
460
434
  const tonalOffset = isTonal
461
435
  ? -1.525 - 0.275 * maskerBark - 4.5
462
436
  : -1.525 - 0.175 * maskerBark - 0.5
463
437
 
464
- // PSD-dependent factors
465
- const psdFactor1 = 0.4 * maskerPsd + 6
438
+ const psdFactor1 = 0.4 * maskerPsd + 6.0
466
439
  const psdFactor2 = 0.15 * maskerPsd
467
440
 
468
- // Calculate masking function
469
441
  let maskingFunction
470
- if (barkDistance < -1) {
471
- maskingFunction = 17 * (barkDistance + 1) - psdFactor1
472
- } else if (barkDistance < 0) {
442
+ if (barkDistance < -1.0) {
443
+ maskingFunction = 17.0 * (barkDistance + 1.0) - psdFactor1
444
+ } else if (barkDistance < 0.0) {
473
445
  maskingFunction = psdFactor1 * barkDistance
474
- } else if (barkDistance < 1) {
475
- maskingFunction = -17 * barkDistance
446
+ } else if (barkDistance < 1.0) {
447
+ maskingFunction = -17.0 * barkDistance
476
448
  } else {
477
- maskingFunction = -(barkDistance - 1) * (17 - psdFactor2) - 17
449
+ maskingFunction = -(barkDistance - 1.0) * (17.0 - psdFactor2) - 17.0
478
450
  }
479
451
 
480
452
  return maskerSpl + tonalOffset + maskingFunction