carta1 1.0.21 → 1.0.22

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.
@@ -1,10 +1,9 @@
1
1
  /**
2
- * Carta1 Audio Codec - MDCT-based Psychoacoustic Analysis
2
+ * Carta1 Audio Codec - MDCT-based Psychoacoustic Analysis (Optimized)
3
3
  *
4
4
  * This module implements the psychoacoustic model for ATRAC1 compression,
5
5
  * analyzing audio signals to determine masking thresholds for perceptual coding.
6
6
  */
7
-
8
7
  import {
9
8
  PSYMODEL_MIN_POWER_DB,
10
9
  PSYMODEL_NOT_EXAMINED,
@@ -20,12 +19,17 @@ import {
20
19
  PSYMODEL_PSD_SOURCE_IDX0,
21
20
  PSYMODEL_PSD_INTERP_WEIGHT,
22
21
  PSYMODEL_PSD_SOURCE_IDX1,
22
+ PSYMODEL_TONAL_OFFSETS_LOW_FREQ,
23
+ PSYMODEL_TONAL_OFFSETS_MID_FREQ,
24
+ PSYMODEL_TONAL_OFFSETS_HIGH_FREQ,
25
+ PSYMODEL_HALF_FFT_SIZE,
26
+ PSYMODEL_NUM_BINS,
23
27
  } from '../core/constants.js'
24
28
 
25
29
  /**
26
30
  * Convert linear power value to decibels
27
31
  * @param {number} value - Linear power value
28
- * @returns {number} Power in decibels
32
+ * @returns {number} Power in decibels, or minimum threshold if value <= 0
29
33
  */
30
34
  export function toDb(value) {
31
35
  return value > 0
@@ -44,160 +48,287 @@ export function fromDb(db) {
44
48
 
45
49
  /**
46
50
  * Perform complete psychoacoustic analysis on MDCT coefficients
51
+ *
52
+ * This function implements the ISO/IEC 11172-3 psychoacoustic model to:
53
+ * 1. Calculate power spectral density from MDCT coefficients
54
+ * 2. Detect tonal and non-tonal maskers in the frequency domain
55
+ * 3. Calculate critical band masking thresholds for bit allocation
56
+ *
47
57
  * @param {Float32Array} mdctCoeffs - MDCT coefficients (512 samples)
48
- * @param {number} normalizationDb - Target maximum power level in dB
49
- * @returns {Object} Analysis results including PSD, maskers, and thresholds
58
+ * @param {number} normalizationDb - Target maximum power level in dB for normalization
59
+ * @param {Object} psychoBuffers - Psychoacoustics working buffers from buffer poool
60
+ * @returns {Object} Analysis results with PSD, maskers, and critical band thresholds
50
61
  */
51
- export function psychoAnalysis(mdctCoeffs, normalizationDb) {
62
+ export function psychoAnalysis(mdctCoeffs, normalizationDb, psychoBuffers) {
52
63
  const fftScale = PSYMODEL_FFT_SIZE / 512
53
64
 
54
- // Calculate power spectral density
55
- const psd = calculatePSDFromMDCT(mdctCoeffs, normalizationDb)
65
+ // Create local index arrays
66
+ const flags = new Int8Array(PSYMODEL_HALF_FFT_SIZE + 1)
67
+ const tonalIdx = new Int32Array(PSYMODEL_HALF_FFT_SIZE)
68
+ const nonTonalIdx = new Int32Array(PSYMODEL_CRITICAL_BANDS.length)
69
+ const maskIsTonal = new Int8Array(PSYMODEL_HALF_FFT_SIZE)
70
+
71
+ // Calculate PSD (dB), store raw linear power and normalization scale
72
+ const { offsetDb } = calculatePSDFromMDCT(
73
+ mdctCoeffs,
74
+ normalizationDb,
75
+ psychoBuffers
76
+ )
56
77
 
57
78
  // Find tonal and non-tonal maskers
58
- const { flags, tonalMaskers, nonTonalMaskers } = findAllMaskers(psd, fftScale)
79
+ const { tonalCount, nonTonalCount } = findAllMaskers(
80
+ psychoBuffers,
81
+ flags,
82
+ tonalIdx,
83
+ nonTonalIdx,
84
+ offsetDb,
85
+ fftScale
86
+ )
59
87
 
60
88
  // Calculate critical band masking thresholds
61
- const criticalBandThresholds = calculateCriticalBandThresholds(
62
- psd,
63
- tonalMaskers,
64
- nonTonalMaskers
89
+ calculateCriticalBandThresholds(
90
+ psychoBuffers,
91
+ tonalIdx,
92
+ nonTonalIdx,
93
+ maskIsTonal,
94
+ tonalCount,
95
+ nonTonalCount
65
96
  )
66
97
 
98
+ // Materialize objects only at the boundary (keep internal typed arrays)
99
+ const tonalMaskers = new Array(tonalCount)
100
+ for (let i = 0; i < tonalCount; i++) {
101
+ tonalMaskers[i] = { index: tonalIdx[i], spl: psychoBuffers.tonalSPL[i] }
102
+ }
103
+ const nonTonalMaskers = new Array(nonTonalCount)
104
+ for (let i = 0; i < nonTonalCount; i++) {
105
+ nonTonalMaskers[i] = {
106
+ index: nonTonalIdx[i],
107
+ spl: psychoBuffers.nonTonalSPL[i],
108
+ }
109
+ }
110
+
67
111
  return {
68
- psd,
112
+ psd: psychoBuffers.psdDb,
69
113
  tonalMaskers,
70
- tonalMaskersCount: tonalMaskers.length,
114
+ tonalMaskersCount: tonalCount,
71
115
  nonTonalMaskers,
72
- nonTonalMaskersCount: nonTonalMaskers.length,
73
- criticalBandThresholds,
116
+ nonTonalMaskersCount: nonTonalCount,
117
+ criticalBandThresholds: psychoBuffers.criticalBandThresholds,
74
118
  flags,
75
119
  }
76
120
  }
77
121
 
78
122
  /**
79
123
  * Calculate Power Spectral Density from MDCT coefficients
80
- * @param {Float32Array} mdctCoeffs - MDCT coefficients
81
- * @returns {Float32Array} Power spectral density in dB
124
+ *
125
+ * Transforms MDCT coefficients to power spectral density using interpolation
126
+ * between adjacent MDCT bins to match the psychoacoustic model's FFT resolution.
127
+ * The output is normalized so the maximum power reaches the target level.
128
+ *
129
+ * @param {Float32Array} mdctCoeffs - Input MDCT coefficients (512 samples)
130
+ * @param {number} normalizationDb - Target maximum power level in dB
131
+ * @param {Object} psychoBuffers - Pre-allocated working buffers
132
+ * @returns {Object} Normalization parameters {offsetDb, linScale}
82
133
  */
83
- function calculatePSDFromMDCT(mdctCoeffs, normalizationDb) {
84
- const half = PSYMODEL_FFT_SIZE >>> 1
85
- const psd = new Float32Array(half + 1)
86
- const linearPower = new Float32Array(half + 1)
134
+ function calculatePSDFromMDCT(mdctCoeffs, normalizationDb, psychoBuffers) {
135
+ const linPower = psychoBuffers.linPower
87
136
 
88
137
  let maxLinearPower = 0
89
- for (let i = 0; i <= half; i++) {
138
+ for (let i = 0; i <= PSYMODEL_HALF_FFT_SIZE; i++) {
90
139
  const a = mdctCoeffs[PSYMODEL_PSD_SOURCE_IDX0[i]]
91
140
  const b = mdctCoeffs[PSYMODEL_PSD_SOURCE_IDX1[i]]
141
+ const w = PSYMODEL_PSD_INTERP_WEIGHT[i]
92
142
 
93
- const power =
94
- a * a * (1 - PSYMODEL_PSD_INTERP_WEIGHT[i]) +
95
- b * b * PSYMODEL_PSD_INTERP_WEIGHT[i]
96
- linearPower[i] = power
97
-
98
- if (power > maxLinearPower) {
99
- maxLinearPower = power
100
- }
143
+ // Calculate interpolated power between adjacent MDCT coefficients
144
+ const interpolatedPower = a * a * (1 - w) + b * b * w
145
+ linPower[i] = interpolatedPower
146
+ if (interpolatedPower > maxLinearPower) maxLinearPower = interpolatedPower
101
147
  }
102
148
 
103
- const offset =
104
- normalizationDb - PSYMODEL_LOG10_FACTOR * Math.log(maxLinearPower || 1)
105
-
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
149
+ // dB offset ensures max reaches normalizationDb
150
+ const offsetDb =
151
+ normalizationDb -
152
+ (maxLinearPower > 0 ? PSYMODEL_LOG10_FACTOR * Math.log(maxLinearPower) : 0)
153
+
154
+ // Calculate linear scale for later fast conversions
155
+ const linScale = Math.exp(offsetDb * PSYMODEL_POW10_FACTOR)
156
+
157
+ // Fill PSD in dB (normalized)
158
+ const psdDb = psychoBuffers.psdDb
159
+ if (maxLinearPower > 0) {
160
+ for (let i = 0; i <= PSYMODEL_HALF_FFT_SIZE; i++) {
161
+ const p = linPower[i]
162
+ psdDb[i] =
163
+ p > 0
164
+ ? PSYMODEL_LOG10_FACTOR * Math.log(p) + offsetDb
165
+ : PSYMODEL_MIN_POWER_DB
112
166
  }
167
+ } else {
168
+ psdDb.fill(PSYMODEL_MIN_POWER_DB, 0, PSYMODEL_NUM_BINS)
113
169
  }
114
170
 
115
- return psd
171
+ return { offsetDb, linScale }
116
172
  }
117
173
 
118
174
  /**
119
- * Find all maskers (tonal and non-tonal) in the spectrum
120
- * @param {Float32Array} psd - Power spectral density
121
- * @param {number} fftScale - FFT scaling factor
122
- * @returns {Object} Flags array and lists of maskers
175
+ * Orchestrate the complete masker detection process
176
+ *
177
+ * Coordinates the detection of both tonal and non-tonal maskers, then filters
178
+ * out maskers that fall below the absolute hearing threshold.
179
+ *
180
+ * @param {Object} psychoBuffers - Pre-allocated working buffers
181
+ * @param {Int8Array} flags - Classification flags for each frequency bin
182
+ * @param {Int32Array} tonalIdx - Output array for tonal masker indices
183
+ * @param {Int32Array} nonTonalIdx - Output array for non-tonal masker indices
184
+ * @param {number} offsetDb - dB offset for power normalization
185
+ * @param {number} fftScale - Scaling factor for FFT bin mapping
186
+ * @returns {Object} Count of detected maskers after filtering
123
187
  */
124
- function findAllMaskers(psd, fftScale) {
125
- // Initialize flags
126
- const flags = new Uint8Array(PSYMODEL_FFT_SIZE / 2)
188
+ function findAllMaskers(
189
+ psychoBuffers,
190
+ flags,
191
+ tonalIdx,
192
+ nonTonalIdx,
193
+ offsetDb,
194
+ fftScale
195
+ ) {
127
196
  flags.fill(PSYMODEL_NOT_EXAMINED)
128
197
 
129
- // Find tonal maskers first
130
- const tonalList = findTonalMaskers(psd, flags, fftScale)
131
-
132
- // Find non-tonal maskers
133
- const nonTonalList = findNonTonalMaskers(flags, psd)
198
+ const tonalCount = findTonalMaskers(
199
+ psychoBuffers,
200
+ flags,
201
+ tonalIdx,
202
+ offsetDb,
203
+ fftScale
204
+ )
205
+ const nonTonalCount = findNonTonalMaskers(
206
+ psychoBuffers,
207
+ flags,
208
+ nonTonalIdx,
209
+ offsetDb
210
+ )
134
211
 
135
- // Apply decimation to remove weak maskers
136
- const decimated = decimateMaskers(flags, tonalList, nonTonalList)
212
+ const { tonal: keptTonal, nonTonal: keptNonTonal } = decimateMaskers(
213
+ psychoBuffers,
214
+ flags,
215
+ tonalIdx,
216
+ nonTonalIdx,
217
+ tonalCount,
218
+ nonTonalCount
219
+ )
137
220
 
138
221
  return {
139
- flags,
140
- tonalMaskers: decimated.tonalList,
141
- nonTonalMaskers: decimated.nonTonalList,
222
+ tonalCount: keptTonal,
223
+ nonTonalCount: keptNonTonal,
142
224
  }
143
225
  }
144
226
 
145
227
  /**
146
- * Detect tonal maskers (peaks in spectrum)
147
- * @param {Float32Array} psd - Power spectral density
148
- * @param {Uint8Array} flags - Component classification flags
149
- * @param {number} fftScale - FFT scaling factor
150
- * @returns {Object} Updated flags and tonal masker list
228
+ * Detect tonal maskers using linear power sums and a global dB offset
229
+ *
230
+ * Identifies frequency bins that represent tonal components by finding local peaks
231
+ * that exceed neighboring bins by a sufficient margin. Uses different neighbor
232
+ * patterns based on frequency range for optimal detection.
233
+ *
234
+ * @param {Object} psychoBuffers - Pre-allocated working buffers
235
+ * @param {Int8Array} flags - Classification flags for each frequency bin
236
+ * @param {Int32Array} tonalIdx - Output array for tonal masker indices
237
+ * @param {number} offsetDb - dB offset for power normalization
238
+ * @param {number} fftScale - Scaling factor for FFT bin mapping
239
+ * @returns {number} Number of tonal maskers detected
151
240
  */
152
- function findTonalMaskers(psd, flags, fftScale) {
153
- const tonalList = []
241
+ function findTonalMaskers(psychoBuffers, flags, tonalIdx, offsetDb, fftScale) {
242
+ const psdDb = psychoBuffers.psdDb
243
+ const linPower = psychoBuffers.linPower
244
+
245
+ let count = 0
154
246
  const maxTonalBin = Math.floor(249 * fftScale)
155
247
 
156
- // Frequency range boundaries
157
248
  const RANGE_LOW = Math.floor(63 * fftScale)
158
249
  const RANGE_MID = Math.floor(127 * fftScale)
159
250
  const RANGE_HIGH = Math.floor(150 * fftScale)
160
251
 
161
- for (let k = 1; k < psd.length - 1 && k <= maxTonalBin; k++) {
162
- // Check for local maximum
163
- if (psd[k] <= psd[k - 1] || psd[k] < psd[k + 1]) continue
164
-
165
- // Determine search range based on frequency
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 {
252
+ for (
253
+ let binIndex = 1, len = psdDb.length - 1;
254
+ binIndex < len && binIndex <= maxTonalBin;
255
+ binIndex++
256
+ ) {
257
+ // Check for local peak in power spectral density
258
+ const binPower = psdDb[binIndex]
259
+ if (binPower <= psdDb[binIndex - 1] || binPower < psdDb[binIndex + 1])
174
260
  continue
175
- }
176
261
 
177
- // Check tonality criteria
178
- if (isTonal(psd, k, searchOffsets)) {
179
- // Calculate combined SPL
180
- const spl = toDb(fromDb(psd[k - 1]) + fromDb(psd[k]) + fromDb(psd[k + 1]))
181
- tonalList.push({ index: k, spl })
182
- markTonalComponent(flags, k, searchOffsets)
262
+ // Select neighbor pattern based on frequency range
263
+ let neighborOffsets = null
264
+ if (binIndex > 2 && binIndex < RANGE_LOW)
265
+ neighborOffsets = PSYMODEL_TONAL_OFFSETS_LOW_FREQ
266
+ else if (binIndex >= RANGE_LOW && binIndex < RANGE_MID)
267
+ neighborOffsets = PSYMODEL_TONAL_OFFSETS_MID_FREQ
268
+ else if (binIndex >= RANGE_MID && binIndex < RANGE_HIGH)
269
+ neighborOffsets = PSYMODEL_TONAL_OFFSETS_HIGH_FREQ
270
+ else continue
271
+
272
+ if (isTonalWithOffsets(psdDb, binIndex, neighborOffsets)) {
273
+ // Calculate SPL using adjacent bins: offsetDb + 10*log10(sum of linear powers)
274
+ const adjacentPowerSum =
275
+ linPower[binIndex - 1] + linPower[binIndex] + linPower[binIndex + 1]
276
+ const soundPressureLevel =
277
+ adjacentPowerSum > 0
278
+ ? offsetDb + PSYMODEL_LOG10_FACTOR * Math.log(adjacentPowerSum)
279
+ : PSYMODEL_MIN_POWER_DB
280
+
281
+ tonalIdx[count] = binIndex
282
+ psychoBuffers.tonalSPL[count] = soundPressureLevel
283
+ count++
284
+
285
+ // Mark this bin as tonal and mark neighbors as irrelevant
286
+ flags[binIndex] = PSYMODEL_TONAL
287
+
288
+ // Mark pattern-specific neighbors as irrelevant
289
+ for (let i = 0; i < neighborOffsets.length; i++) {
290
+ const neighborIdx = binIndex + neighborOffsets[i]
291
+ if (
292
+ neighborIdx >= 0 &&
293
+ neighborIdx < flags.length &&
294
+ neighborIdx !== binIndex
295
+ ) {
296
+ flags[neighborIdx] = PSYMODEL_IRRELEVANT
297
+ }
298
+ }
299
+
300
+ // Mark immediate neighbors as irrelevant
301
+ if (binIndex - 1 >= 0) flags[binIndex - 1] = PSYMODEL_IRRELEVANT
302
+ if (binIndex + 1 < flags.length) flags[binIndex + 1] = PSYMODEL_IRRELEVANT
183
303
  }
184
304
  }
185
305
 
186
- return tonalList
306
+ return count
187
307
  }
188
308
 
189
309
  /**
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
310
+ * Check if a frequency bin qualifies as tonal based on neighbor comparison
311
+ *
312
+ * A bin is considered tonal if it exceeds all specified neighbor bins
313
+ * by at least the tonal threshold (7 dB).
314
+ *
315
+ * @param {Float32Array} psdDb - Power spectral density in dB
316
+ * @param {number} binIndex - Index of the bin to test
317
+ * @param {Int8Array} offsets - Neighbor offset pattern to check
318
+ * @returns {boolean} True if the bin qualifies as tonal
195
319
  */
196
- function isTonal(psd, k, offsets) {
320
+ function isTonalWithOffsets(psdDb, binIndex, offsets) {
197
321
  const TONAL_THRESHOLD = 7.0
198
- for (const offset of offsets) {
199
- const idx = k + offset
200
- if (idx >= 0 && idx < psd.length && psd[k] - psd[idx] < TONAL_THRESHOLD) {
322
+ const binPower = psdDb[binIndex]
323
+ const arrayLength = psdDb.length
324
+
325
+ for (let i = 0; i < offsets.length; i++) {
326
+ const neighborIndex = binIndex + offsets[i]
327
+ if (
328
+ neighborIndex >= 0 &&
329
+ neighborIndex < arrayLength &&
330
+ binPower - psdDb[neighborIndex] < TONAL_THRESHOLD
331
+ ) {
201
332
  return false
202
333
  }
203
334
  }
@@ -205,249 +336,296 @@ function isTonal(psd, k, offsets) {
205
336
  }
206
337
 
207
338
  /**
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
212
- */
213
- function markTonalComponent(flags, k, searchOffsets) {
214
- flags[k] = PSYMODEL_TONAL
215
- for (const offset of [...searchOffsets, -1, 1]) {
216
- const idx = k + offset
217
- if (idx >= 0 && idx < flags.length && idx !== k) {
218
- flags[idx] = PSYMODEL_IRRELEVANT
219
- }
220
- }
221
- }
222
-
223
- /**
224
- * Detect non-tonal maskers (noise-like components)
225
- * @param {Uint8Array} flags - Component classification flags
226
- * @param {Float32Array} psd - Power spectral density
227
- * @returns {Object} Updated flags and non-tonal masker list
339
+ * Detect non-tonal maskers per critical band using linear power aggregation
340
+ *
341
+ * Groups unexamined frequency bins by critical band and calculates the energy
342
+ * centroid for each band. Creates non-tonal maskers at the centroid positions
343
+ * to represent the noise-like spectral content.
344
+ *
345
+ * @param {Object} psychoBuffers - Pre-allocated working buffers
346
+ * @param {Int8Array} flags - Classification flags for each frequency bin
347
+ * @param {Int32Array} nonTonalIdx - Output array for non-tonal masker indices
348
+ * @param {number} offsetDb - dB offset for power normalization
349
+ * @returns {number} Number of non-tonal maskers detected
228
350
  */
229
- function findNonTonalMaskers(flags, psd) {
230
- const nonTonalList = []
231
-
232
- for (let i = 0; i < PSYMODEL_CRITICAL_BANDS.length - 1; i++) {
233
- const cbStart = PSYMODEL_THRESHOLD_TABLE[PSYMODEL_CRITICAL_BANDS[i]][0] - 1
234
- const cbEnd =
235
- PSYMODEL_THRESHOLD_TABLE[PSYMODEL_CRITICAL_BANDS[i + 1]][0] - 1
236
-
237
- const bandAnalysis = analyzeCriticalBand(
238
- psd,
239
- flags,
240
- cbStart,
241
- cbEnd,
242
- PSYMODEL_CRITICAL_BANDS[i]
243
- )
244
-
245
- if (bandAnalysis.powerDb > PSYMODEL_MIN_POWER_DB) {
246
- // Place masker at energy centroid
247
- let index = Math.round(cbStart + bandAnalysis.centroidOffset)
248
- index = Math.max(0, Math.min(index, flags.length - 1))
249
-
250
- // Avoid conflict with tonal maskers
251
- if (flags[index] === PSYMODEL_TONAL) {
252
- index = Math.min(index + 1, flags.length - 1)
351
+ function findNonTonalMaskers(psychoBuffers, flags, nonTonalIdx, offsetDb) {
352
+ const linPower = psychoBuffers.linPower
353
+
354
+ let count = 0
355
+ const cbList = PSYMODEL_CRITICAL_BANDS
356
+ const mapLen = PSYMODEL_FREQ_TO_CB_MAP.length
357
+ const flagsLen = flags.length
358
+
359
+ for (let bandIndex = 0; bandIndex < cbList.length - 1; bandIndex++) {
360
+ const currentBandIdx = cbList[bandIndex]
361
+ const bandStartBin = PSYMODEL_THRESHOLD_TABLE[currentBandIdx][0] - 1
362
+ const bandEndBin = PSYMODEL_THRESHOLD_TABLE[cbList[bandIndex + 1]][0] - 1
363
+
364
+ // Analyze critical band in linear power domain
365
+ let totalLinearPower = 0
366
+ let weightedLinearPower = 0
367
+ const baseBarkValue = PSYMODEL_THRESHOLD_TABLE[currentBandIdx][1]
368
+
369
+ const endBin = Math.min(bandEndBin, flagsLen)
370
+ for (let binIndex = bandStartBin; binIndex < endBin; binIndex++) {
371
+ if (flags[binIndex] === PSYMODEL_NOT_EXAMINED) {
372
+ const binPower = linPower[binIndex]
373
+ totalLinearPower += binPower
374
+ if (binIndex < mapLen) {
375
+ const barkValue =
376
+ PSYMODEL_THRESHOLD_TABLE[PSYMODEL_FREQ_TO_CB_MAP[binIndex]][1]
377
+ weightedLinearPower += binPower * (barkValue - baseBarkValue)
378
+ }
379
+ flags[binIndex] = PSYMODEL_IRRELEVANT
253
380
  }
254
-
255
- nonTonalList.push({ index, spl: bandAnalysis.powerDb })
256
- flags[index] = PSYMODEL_NON_TONAL
257
381
  }
258
- }
259
-
260
- return nonTonalList
261
- }
262
-
263
- /**
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
271
- */
272
- function analyzeCriticalBand(psd, flags, startBin, endBin, bandIndex) {
273
- let totalPower = 0
274
- let weightedPower = 0
275
- const baseBark = PSYMODEL_THRESHOLD_TABLE[bandIndex][1]
276
-
277
- for (let k = startBin; k < endBin && k < flags.length; k++) {
278
- if (flags[k] === PSYMODEL_NOT_EXAMINED) {
279
- const power = fromDb(psd[k])
280
- totalPower += power
281
-
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
- }
287
382
 
288
- flags[k] = PSYMODEL_IRRELEVANT
383
+ if (totalLinearPower > 0) {
384
+ // Calculate energy centroid offset within the critical band
385
+ const centroidOffset =
386
+ (weightedLinearPower / totalLinearPower) * (bandEndBin - bandStartBin)
387
+
388
+ // Convert total power to dB: offsetDb + 10*log10(totalLinearPower)
389
+ const maskerPowerDb =
390
+ offsetDb + PSYMODEL_LOG10_FACTOR * Math.log(totalLinearPower)
391
+
392
+ // Place non-tonal masker near energy centroid (avoid tonal slots)
393
+ let maskerIndex = Math.round(bandStartBin + centroidOffset)
394
+ maskerIndex =
395
+ maskerIndex < 0
396
+ ? 0
397
+ : maskerIndex >= flagsLen
398
+ ? flagsLen - 1
399
+ : maskerIndex
400
+ if (flags[maskerIndex] === PSYMODEL_TONAL && maskerIndex + 1 < flagsLen)
401
+ maskerIndex++
402
+
403
+ nonTonalIdx[count] = maskerIndex
404
+ psychoBuffers.nonTonalSPL[count] = maskerPowerDb
405
+ flags[maskerIndex] = PSYMODEL_NON_TONAL
406
+ count++
289
407
  }
290
408
  }
291
409
 
292
- return {
293
- powerDb: toDb(totalPower),
294
- centroidOffset:
295
- totalPower > 0 ? (weightedPower / totalPower) * (endBin - startBin) : 0,
296
- }
410
+ return count
297
411
  }
298
412
 
299
413
  /**
300
- * Remove weak maskers below absolute threshold
301
- * @param {Uint8Array} flags - Component classification flags
302
- * @param {Array} tonalList - List of tonal maskers
303
- * @param {Array} nonTonalList - List of non-tonal maskers
304
- * @returns {Object} Filtered masker lists
414
+ * Remove weak maskers below absolute hearing threshold
415
+ *
416
+ * Filters out maskers that fall below the absolute threshold of hearing
417
+ * using the psychoacoustic model's threshold table. Compacts the arrays
418
+ * in-place to maintain efficiency.
419
+ *
420
+ * @param {Object} psychoBuffers - Pre-allocated working buffers
421
+ * @param {Int8Array} flags - Classification flags for each frequency bin
422
+ * @param {Int32Array} tonalIdx - Array of tonal masker indices
423
+ * @param {Int32Array} nonTonalIdx - Array of non-tonal masker indices
424
+ * @param {number} tonalCount - Number of detected tonal maskers
425
+ * @param {number} nonTonalCount - Number of detected non-tonal maskers
426
+ * @returns {Object} Counts of audible maskers after filtering
305
427
  */
306
- function decimateMaskers(flags, tonalList, nonTonalList) {
307
- const isAudible = (masker) => {
308
- const k = masker.index
309
- return (
310
- k < PSYMODEL_FREQ_TO_CB_MAP.length &&
311
- masker.spl >= PSYMODEL_THRESHOLD_TABLE[PSYMODEL_FREQ_TO_CB_MAP[k]][2]
312
- )
313
- }
314
-
315
- const markInaudible = (masker) => {
316
- if (masker.index < flags.length) {
317
- flags[masker.index] = PSYMODEL_IRRELEVANT
318
- }
428
+ function decimateMaskers(
429
+ psychoBuffers,
430
+ flags,
431
+ tonalIdx,
432
+ nonTonalIdx,
433
+ tonalCount,
434
+ nonTonalCount
435
+ ) {
436
+ const isAudible = (idx, spl) => {
437
+ if (idx >= PSYMODEL_FREQ_TO_CB_MAP.length) return false
438
+ const cb = PSYMODEL_FREQ_TO_CB_MAP[idx]
439
+ return spl >= PSYMODEL_THRESHOLD_TABLE[cb][2]
319
440
  }
320
441
 
321
- // Filter tonal maskers
322
- const audibleTonal = []
323
- for (const tonal of tonalList) {
324
- if (isAudible(tonal)) {
325
- audibleTonal.push(tonal)
326
- } else {
327
- markInaudible(tonal)
442
+ let keptTonal = 0
443
+ for (let i = 0; i < tonalCount; i++) {
444
+ const idx = tonalIdx[i]
445
+ const spl = psychoBuffers.tonalSPL[i]
446
+ if (isAudible(idx, spl)) {
447
+ // compact in-place
448
+ if (keptTonal !== i) {
449
+ tonalIdx[keptTonal] = idx
450
+ psychoBuffers.tonalSPL[keptTonal] = spl
451
+ }
452
+ keptTonal++
453
+ } else if (idx < flags.length) {
454
+ flags[idx] = PSYMODEL_IRRELEVANT
328
455
  }
329
456
  }
330
457
 
331
- // Filter non-tonal maskers
332
- const audibleNonTonal = []
333
- for (const nonTonal of nonTonalList) {
334
- if (isAudible(nonTonal)) {
335
- audibleNonTonal.push(nonTonal)
336
- } else {
337
- markInaudible(nonTonal)
458
+ let keptNonTonal = 0
459
+ for (let i = 0; i < nonTonalCount; i++) {
460
+ const idx = nonTonalIdx[i]
461
+ const spl = psychoBuffers.nonTonalSPL[i]
462
+ if (isAudible(idx, spl)) {
463
+ if (keptNonTonal !== i) {
464
+ nonTonalIdx[keptNonTonal] = idx
465
+ psychoBuffers.nonTonalSPL[keptNonTonal] = spl
466
+ }
467
+ keptNonTonal++
468
+ } else if (idx < flags.length) {
469
+ flags[idx] = PSYMODEL_IRRELEVANT
338
470
  }
339
471
  }
340
472
 
341
- return {
342
- tonalList: audibleTonal,
343
- nonTonalList: audibleNonTonal,
344
- }
473
+ return { tonal: keptTonal, nonTonal: keptNonTonal }
345
474
  }
346
475
 
347
476
  /**
348
- * Calculate critical band masking thresholds
349
- * @param {Float32Array} psd - Power spectral density
350
- * @param {Array} tonalMaskers - List of tonal maskers
351
- * @param {Array} nonTonalMaskers - List of non-tonal maskers
352
- * @returns {Float32Array} Critical band thresholds
477
+ * Precompute masker features used in threshold summation
478
+ *
479
+ * Collects all audible maskers and precomputes their bark scale values,
480
+ * tonal offsets, and masking function coefficients to optimize the
481
+ * critical band threshold calculation.
482
+ *
483
+ * @param {Object} psychoBuffers - Pre-allocated working buffers
484
+ * @param {Int32Array} tonalIdx - Array of tonal masker indices
485
+ * @param {Int32Array} nonTonalIdx - Array of non-tonal masker indices
486
+ * @param {Int8Array} maskIsTonal - Array indicating masker type (1=tonal, 0=non-tonal)
487
+ * @param {number} tonalCount - Number of audible tonal maskers
488
+ * @param {number} nonTonalCount - Number of audible non-tonal maskers
489
+ * @returns {number} Total number of maskers collected
353
490
  */
354
- function calculateCriticalBandThresholds(psd, tonalMaskers, nonTonalMaskers) {
355
- const criticalBandThresholds = new Float32Array(
356
- PSYMODEL_CRITICAL_BANDS.length
357
- )
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
- })
491
+ function collectMaskers(
492
+ psychoBuffers,
493
+ tonalIdx,
494
+ nonTonalIdx,
495
+ maskIsTonal,
496
+ tonalCount,
497
+ nonTonalCount
498
+ ) {
499
+ const psd = psychoBuffers.psdDb
500
+ const maskSpl = psychoBuffers.maskSpl
501
+ const maskPsd = psychoBuffers.maskPsd
502
+ const maskBark = psychoBuffers.maskBark
503
+ const maskTonalOffset = psychoBuffers.maskTonalOffset
504
+ const maskPsdF1 = psychoBuffers.maskPsdF1
505
+ const maskPsdF2 = psychoBuffers.maskPsdF2
506
+
507
+ let maskerIndex = 0
508
+
509
+ // Process tonal maskers
510
+ for (let i = 0; i < tonalCount; i++) {
511
+ const binIndex = tonalIdx[i]
512
+ if (binIndex < PSYMODEL_FREQ_TO_CB_MAP.length) {
513
+ const barkValue =
514
+ PSYMODEL_THRESHOLD_TABLE[PSYMODEL_FREQ_TO_CB_MAP[binIndex]][1]
515
+ const soundPressureLevel = psychoBuffers.tonalSPL[i]
516
+ const psdValue = psd[binIndex]
517
+
518
+ maskSpl[maskerIndex] = soundPressureLevel
519
+ maskPsd[maskerIndex] = psdValue
520
+ maskBark[maskerIndex] = barkValue
521
+ maskIsTonal[maskerIndex] = 1
522
+ maskTonalOffset[maskerIndex] = -1.525 - 0.275 * barkValue - 4.5 // Tonal offset formula
523
+ maskPsdF1[maskerIndex] = 0.4 * psdValue + 6.0
524
+ maskPsdF2[maskerIndex] = 0.15 * psdValue
525
+ maskerIndex++
371
526
  }
372
527
  }
373
528
 
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
- })
529
+ // Process non-tonal maskers
530
+ for (let i = 0; i < nonTonalCount; i++) {
531
+ const binIndex = nonTonalIdx[i]
532
+ if (binIndex < PSYMODEL_FREQ_TO_CB_MAP.length) {
533
+ const barkValue =
534
+ PSYMODEL_THRESHOLD_TABLE[PSYMODEL_FREQ_TO_CB_MAP[binIndex]][1]
535
+ const soundPressureLevel = psychoBuffers.nonTonalSPL[i]
536
+ const psdValue = psd[binIndex]
537
+
538
+ maskSpl[maskerIndex] = soundPressureLevel
539
+ maskPsd[maskerIndex] = psdValue
540
+ maskBark[maskerIndex] = barkValue
541
+ maskIsTonal[maskerIndex] = 0
542
+ maskTonalOffset[maskerIndex] = -1.525 - 0.175 * barkValue - 0.5 // Non-tonal offset formula
543
+ maskPsdF1[maskerIndex] = 0.4 * psdValue + 6.0
544
+ maskPsdF2[maskerIndex] = 0.15 * psdValue
545
+ maskerIndex++
383
546
  }
384
547
  }
385
548
 
386
- // Calculate threshold for each critical band
387
- for (let i = 0; i < PSYMODEL_CRITICAL_BANDS.length; i++) {
388
- const bandData = PSYMODEL_THRESHOLD_TABLE[PSYMODEL_CRITICAL_BANDS[i]]
389
- const maskedBark = bandData[1]
390
- const quietThreshold = bandData[2]
391
-
392
- // Start with threshold in quiet
393
- let summedEnergy = fromDb(quietThreshold)
394
-
395
- // Add contributions from all maskers
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
-
412
- criticalBandThresholds[i] = toDb(summedEnergy)
413
- }
414
-
415
- return criticalBandThresholds
549
+ return maskerIndex
416
550
  }
417
551
 
418
552
  /**
419
- * Calculate energy contributions from a set of maskers
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
553
+ * Calculate critical band masking thresholds using precomputed masker features
554
+ *
555
+ * Computes the masking threshold for each critical band by summing the
556
+ * contributions from all maskers, taking into account the masking function
557
+ * shape and bark scale distances.
558
+ *
559
+ * @param {Object} psychoBuffers - Pre-allocated working buffers
560
+ * @param {Int32Array} tonalIdx - Array of tonal masker indices
561
+ * @param {Int32Array} nonTonalIdx - Array of non-tonal masker indices
562
+ * @param {Int8Array} maskIsTonal - Array indicating masker type
563
+ * @param {number} tonalCount - Number of audible tonal maskers
564
+ * @param {number} nonTonalCount - Number of audible non-tonal maskers
426
565
  */
427
- function calculateMaskingLevel(
428
- maskerSpl,
429
- maskerPsd,
430
- maskerBark,
431
- barkDistance,
432
- isTonal
566
+ function calculateCriticalBandThresholds(
567
+ psychoBuffers,
568
+ tonalIdx,
569
+ nonTonalIdx,
570
+ maskIsTonal,
571
+ tonalCount,
572
+ nonTonalCount
433
573
  ) {
434
- const tonalOffset = isTonal
435
- ? -1.525 - 0.275 * maskerBark - 4.5
436
- : -1.525 - 0.175 * maskerBark - 0.5
437
-
438
- const psdFactor1 = 0.4 * maskerPsd + 6.0
439
- const psdFactor2 = 0.15 * maskerPsd
440
-
441
- let maskingFunction
442
- if (barkDistance < -1.0) {
443
- maskingFunction = 17.0 * (barkDistance + 1.0) - psdFactor1
444
- } else if (barkDistance < 0.0) {
445
- maskingFunction = psdFactor1 * barkDistance
446
- } else if (barkDistance < 1.0) {
447
- maskingFunction = -17.0 * barkDistance
448
- } else {
449
- maskingFunction = -(barkDistance - 1.0) * (17.0 - psdFactor2) - 17.0
450
- }
574
+ const cbList = PSYMODEL_CRITICAL_BANDS
575
+ const thresholds = psychoBuffers.criticalBandThresholds
576
+
577
+ const maskersCount = collectMaskers(
578
+ psychoBuffers,
579
+ tonalIdx,
580
+ nonTonalIdx,
581
+ maskIsTonal,
582
+ tonalCount,
583
+ nonTonalCount
584
+ )
585
+ const maskSpl = psychoBuffers.maskSpl
586
+ const maskBark = psychoBuffers.maskBark
587
+ const maskTonalOffset = psychoBuffers.maskTonalOffset
588
+ const maskPsdF1 = psychoBuffers.maskPsdF1
589
+ const maskPsdF2 = psychoBuffers.maskPsdF2
590
+
591
+ // Calculate masking threshold for each critical band
592
+ for (let bandIndex = 0; bandIndex < cbList.length; bandIndex++) {
593
+ const bandData = PSYMODEL_THRESHOLD_TABLE[cbList[bandIndex]]
594
+ const maskedBarkValue = bandData[1]
595
+ const absoluteThreshold = bandData[2]
596
+
597
+ // Start with absolute threshold of hearing (quiet threshold)
598
+ let totalLinearEnergy = fromDb(absoluteThreshold)
599
+
600
+ // Sum contributions from all maskers
601
+ for (let maskerIdx = 0; maskerIdx < maskersCount; maskerIdx++) {
602
+ const barkDistance = maskedBarkValue - maskBark[maskerIdx]
603
+
604
+ // Check if masker is within effective masking range
605
+ if (barkDistance >= -3 && barkDistance < 8) {
606
+ // Calculate piecewise masking function using precomputed terms
607
+ const f1 = maskPsdF1[maskerIdx]
608
+ const f2 = maskPsdF2[maskerIdx]
609
+ let maskingFunction
610
+
611
+ if (barkDistance < -1.0) {
612
+ maskingFunction = 17.0 * (barkDistance + 1.0) - f1
613
+ } else if (barkDistance < 0.0) {
614
+ maskingFunction = f1 * barkDistance
615
+ } else if (barkDistance < 1.0) {
616
+ maskingFunction = -17.0 * barkDistance
617
+ } else {
618
+ maskingFunction = -(barkDistance - 1.0) * (17.0 - f2) - 17.0
619
+ }
620
+
621
+ // Calculate final masked threshold and add to total
622
+ const maskedThresholdDb =
623
+ maskSpl[maskerIdx] + maskTonalOffset[maskerIdx] + maskingFunction
624
+ totalLinearEnergy += fromDb(maskedThresholdDb)
625
+ }
626
+ }
451
627
 
452
- return maskerSpl + tonalOffset + maskingFunction
628
+ // Convert back to dB and store final threshold
629
+ thresholds[bandIndex] = toDb(totalLinearEnergy)
630
+ }
453
631
  }