carta1 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +129 -0
- package/bin/cli.js +679 -0
- package/codec/analysis/psychoacoustics.js +589 -0
- package/codec/analysis/transient.js +226 -0
- package/codec/browser/interface.js +188 -0
- package/codec/browser/worker.js +140 -0
- package/codec/coding/bitallocation.js +407 -0
- package/codec/coding/quantization.js +150 -0
- package/codec/core/bitstream.js +82 -0
- package/codec/core/buffers.js +99 -0
- package/codec/core/constants.js +321 -0
- package/codec/core/options.js +151 -0
- package/codec/index.js +27 -0
- package/codec/io/processor.js +566 -0
- package/codec/io/readers.js +56 -0
- package/codec/io/serialization.js +254 -0
- package/codec/pipeline/decoder.js +278 -0
- package/codec/pipeline/encoder.js +369 -0
- package/codec/transforms/fft.js +69 -0
- package/codec/transforms/mdct.js +225 -0
- package/codec/transforms/qmf.js +127 -0
- package/codec/utils.js +151 -0
- package/dist/carta1-worker-interface.min.js +2 -0
- package/dist/carta1-worker-interface.min.js.map +1 -0
- package/dist/carta1-worker.min.js +2 -0
- package/dist/carta1-worker.min.js.map +1 -0
- package/dist/carta1.min.js +2 -0
- package/dist/carta1.min.js.map +1 -0
- package/package.json +77 -0
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carta1 Audio Codec - MDCT-based Psychoacoustic Analysis
|
|
3
|
+
*
|
|
4
|
+
* This module implements the psychoacoustic model for ATRAC1 compression,
|
|
5
|
+
* analyzing audio signals to determine masking thresholds for perceptual coding.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
PSYMODEL_MIN_POWER_DB,
|
|
10
|
+
PSYMODEL_NOT_EXAMINED,
|
|
11
|
+
PSYMODEL_TONAL,
|
|
12
|
+
PSYMODEL_NON_TONAL,
|
|
13
|
+
PSYMODEL_IRRELEVANT,
|
|
14
|
+
PSYMODEL_LOG10_FACTOR,
|
|
15
|
+
PSYMODEL_POW10_FACTOR,
|
|
16
|
+
PSYMODEL_CRITICAL_BANDS,
|
|
17
|
+
PSYMODEL_CB_FREQ_INDICES,
|
|
18
|
+
PSYMODEL_THRESHOLD_TABLE,
|
|
19
|
+
PSYMODEL_FREQ_TO_CB_MAP,
|
|
20
|
+
} from '../core/constants.js'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Convert linear power value to decibels
|
|
24
|
+
* @param {number} value - Linear power value
|
|
25
|
+
* @returns {number} Power in decibels
|
|
26
|
+
*/
|
|
27
|
+
export function toDb(value) {
|
|
28
|
+
return value > 0
|
|
29
|
+
? PSYMODEL_LOG10_FACTOR * Math.log(value)
|
|
30
|
+
: PSYMODEL_MIN_POWER_DB
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Convert decibels to linear power value
|
|
35
|
+
* @param {number} db - Power in decibels
|
|
36
|
+
* @returns {number} Linear power value
|
|
37
|
+
*/
|
|
38
|
+
export function fromDb(db) {
|
|
39
|
+
return Math.exp(db * PSYMODEL_POW10_FACTOR)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Perform complete psychoacoustic analysis on MDCT coefficients
|
|
44
|
+
* @param {Float32Array} mdctCoeffs - MDCT coefficients (512 samples)
|
|
45
|
+
* @param {number} targetSize - Target FFT size for analysis
|
|
46
|
+
* @param {Object} psychoBuffers - Reusable buffers for performance
|
|
47
|
+
* @returns {Object} Analysis results including PSD, maskers, and thresholds
|
|
48
|
+
*/
|
|
49
|
+
export function psychoAnalysis(
|
|
50
|
+
mdctCoeffs,
|
|
51
|
+
targetSize,
|
|
52
|
+
psychoBuffers,
|
|
53
|
+
normalizationDb
|
|
54
|
+
) {
|
|
55
|
+
const fftScale = targetSize / 512
|
|
56
|
+
|
|
57
|
+
// Apply frequency band reversal correction
|
|
58
|
+
const correctedCoeffs = applyMdctCorrection(mdctCoeffs)
|
|
59
|
+
|
|
60
|
+
// Calculate power spectral density
|
|
61
|
+
const psd = calculatePSDFromMDCT(correctedCoeffs, targetSize, normalizationDb)
|
|
62
|
+
|
|
63
|
+
// Find tonal and non-tonal maskers
|
|
64
|
+
const { flags, tonalMaskers, nonTonalMaskers } = findAllMaskers(
|
|
65
|
+
psd,
|
|
66
|
+
psychoBuffers,
|
|
67
|
+
fftScale
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
// Calculate global masking threshold
|
|
71
|
+
const globalThreshold = calculateGlobalThreshold(
|
|
72
|
+
psd,
|
|
73
|
+
tonalMaskers,
|
|
74
|
+
nonTonalMaskers,
|
|
75
|
+
psychoBuffers.threshold,
|
|
76
|
+
targetSize
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
psd,
|
|
81
|
+
tonalMaskers,
|
|
82
|
+
nonTonalMaskers,
|
|
83
|
+
globalThreshold,
|
|
84
|
+
flags,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Apply frequency band reversal correction to MDCT coefficients
|
|
90
|
+
* @param {Float32Array} mdctCoeffs - Original MDCT coefficients
|
|
91
|
+
* @returns {Float32Array} Corrected coefficients
|
|
92
|
+
*/
|
|
93
|
+
function applyMdctCorrection(mdctCoeffs) {
|
|
94
|
+
const corrected = new Float32Array(512)
|
|
95
|
+
|
|
96
|
+
// Low band: direct copy
|
|
97
|
+
corrected.set(mdctCoeffs.subarray(0, 128), 0)
|
|
98
|
+
|
|
99
|
+
// Mid band: reversed
|
|
100
|
+
for (let i = 0; i < 128; i++) {
|
|
101
|
+
corrected[128 + i] = mdctCoeffs[255 - i]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// High band: reversed
|
|
105
|
+
for (let i = 0; i < 256; i++) {
|
|
106
|
+
corrected[256 + i] = mdctCoeffs[511 - i]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return corrected
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Calculate Power Spectral Density from MDCT coefficients
|
|
114
|
+
* @param {Float32Array} mdctCoeffs - MDCT coefficients
|
|
115
|
+
* @param {number} targetSize - Target FFT size
|
|
116
|
+
* @returns {Float32Array} Power spectral density in dB
|
|
117
|
+
*/
|
|
118
|
+
function calculatePSDFromMDCT(mdctCoeffs, targetSize, normalizationDb) {
|
|
119
|
+
const halfTargetSize = targetSize / 2
|
|
120
|
+
const psd = new Float32Array(halfTargetSize + 1)
|
|
121
|
+
|
|
122
|
+
// Calculate power values and find maximum
|
|
123
|
+
const powers = new Float32Array(512)
|
|
124
|
+
let maxPower = 0
|
|
125
|
+
|
|
126
|
+
for (let i = 0; i < 512; i++) {
|
|
127
|
+
const power = mdctCoeffs[i] * mdctCoeffs[i]
|
|
128
|
+
powers[i] = power
|
|
129
|
+
maxPower = Math.max(maxPower, power)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const normFactor = 1 / (maxPower || 1)
|
|
133
|
+
let maxPsdValue = -Infinity
|
|
134
|
+
|
|
135
|
+
if (targetSize === 512) {
|
|
136
|
+
// Direct averaging for same size
|
|
137
|
+
for (let i = 0; i < 256; i++) {
|
|
138
|
+
const avgPower = (powers[i * 2] + powers[i * 2 + 1]) * 0.5 * normFactor
|
|
139
|
+
psd[i] = avgPower > 0 ? toDb(avgPower) : PSYMODEL_MIN_POWER_DB
|
|
140
|
+
maxPsdValue = Math.max(maxPsdValue, psd[i])
|
|
141
|
+
}
|
|
142
|
+
psd[256] = psd[255]
|
|
143
|
+
// Check if last value is the max
|
|
144
|
+
maxPsdValue = Math.max(maxPsdValue, psd[256])
|
|
145
|
+
} else {
|
|
146
|
+
// Interpolate for different sizes
|
|
147
|
+
const scaleFactor = 512 / targetSize
|
|
148
|
+
|
|
149
|
+
for (let i = 0; i <= halfTargetSize; i++) {
|
|
150
|
+
const srcIdx = i * scaleFactor
|
|
151
|
+
const srcIdxInt = Math.floor(srcIdx)
|
|
152
|
+
const frac = srcIdx - srcIdxInt
|
|
153
|
+
|
|
154
|
+
const power =
|
|
155
|
+
srcIdxInt < 511
|
|
156
|
+
? powers[srcIdxInt] * (1 - frac) + powers[srcIdxInt + 1] * frac
|
|
157
|
+
: powers[511]
|
|
158
|
+
|
|
159
|
+
const normalizedPower = power * normFactor
|
|
160
|
+
psd[i] =
|
|
161
|
+
normalizedPower > 0 ? toDb(normalizedPower) : PSYMODEL_MIN_POWER_DB
|
|
162
|
+
|
|
163
|
+
maxPsdValue = Math.max(maxPsdValue, psd[i])
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Apply normalization to target maximum
|
|
168
|
+
const normalizationDelta = normalizationDb - maxPsdValue
|
|
169
|
+
|
|
170
|
+
for (let i = 0; i < psd.length; i++) {
|
|
171
|
+
if (psd[i] > PSYMODEL_MIN_POWER_DB) {
|
|
172
|
+
psd[i] += normalizationDelta
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return psd
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Find all maskers (tonal and non-tonal) in the spectrum
|
|
181
|
+
* @param {Float32Array} psd - Power spectral density
|
|
182
|
+
* @param {Object} psychoBuffers - Reusable buffers
|
|
183
|
+
* @param {number} fftScale - FFT scaling factor
|
|
184
|
+
* @returns {Object} Flags array and lists of maskers
|
|
185
|
+
*/
|
|
186
|
+
function findAllMaskers(psd, psychoBuffers, fftScale) {
|
|
187
|
+
// Initialize flags
|
|
188
|
+
const flags = psychoBuffers.flags
|
|
189
|
+
flags.fill(PSYMODEL_NOT_EXAMINED)
|
|
190
|
+
|
|
191
|
+
// Find tonal maskers first
|
|
192
|
+
let { tonalList } = findTonalMaskers(psd, flags, fftScale)
|
|
193
|
+
|
|
194
|
+
// Find non-tonal maskers
|
|
195
|
+
let { nonTonalList } = findNonTonalMaskers(flags, psd)
|
|
196
|
+
|
|
197
|
+
// Apply decimation to remove weak maskers
|
|
198
|
+
const decimated = decimateMaskers(flags, tonalList, nonTonalList)
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
flags,
|
|
202
|
+
tonalMaskers: decimated.tonalList,
|
|
203
|
+
nonTonalMaskers: decimated.nonTonalList,
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Detect tonal maskers (peaks in spectrum)
|
|
209
|
+
* @param {Float32Array} psd - Power spectral density
|
|
210
|
+
* @param {Uint8Array} flags - Component classification flags
|
|
211
|
+
* @param {number} fftScale - FFT scaling factor
|
|
212
|
+
* @returns {Object} Updated flags and tonal masker list
|
|
213
|
+
*/
|
|
214
|
+
function findTonalMaskers(psd, flags, fftScale) {
|
|
215
|
+
const tonalList = []
|
|
216
|
+
const maxTonalBin = Math.floor(249 * fftScale)
|
|
217
|
+
const fftSize = (psd.length - 1) * 2
|
|
218
|
+
|
|
219
|
+
// Frequency range boundaries
|
|
220
|
+
const RANGE_LOW = Math.floor(63 * fftScale)
|
|
221
|
+
const RANGE_MID = Math.floor(127 * fftScale)
|
|
222
|
+
const RANGE_HIGH = Math.floor(150 * fftScale)
|
|
223
|
+
|
|
224
|
+
for (let k = 1; k < fftSize / 2 - 1 && k <= maxTonalBin; k++) {
|
|
225
|
+
// Check for local maximum
|
|
226
|
+
if (psd[k] <= psd[k - 1] || psd[k] < psd[k + 1]) continue
|
|
227
|
+
|
|
228
|
+
// Determine search range based on frequency
|
|
229
|
+
const searchOffsets = getSearchOffsets(k, RANGE_LOW, RANGE_MID, RANGE_HIGH)
|
|
230
|
+
if (!searchOffsets) continue
|
|
231
|
+
|
|
232
|
+
// Check tonality criteria
|
|
233
|
+
if (isTonal(psd, k, searchOffsets)) {
|
|
234
|
+
// Calculate combined SPL
|
|
235
|
+
const spl = toDb(fromDb(psd[k - 1]) + fromDb(psd[k]) + fromDb(psd[k + 1]))
|
|
236
|
+
|
|
237
|
+
tonalList.push({ index: k, spl })
|
|
238
|
+
markTonalComponent(flags, k, searchOffsets)
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return { flags, tonalList }
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Get search offsets for tonality check based on frequency
|
|
247
|
+
*/
|
|
248
|
+
function getSearchOffsets(k, rangeLow, rangeMid, rangeHigh) {
|
|
249
|
+
if (k > 2 && k < rangeLow) return [-2, 2]
|
|
250
|
+
if (k >= rangeLow && k < rangeMid) return [-3, -2, 2, 3]
|
|
251
|
+
if (k >= rangeMid && k < rangeHigh) return [-6, -5, -4, -3, -2, 2, 3, 4, 5, 6]
|
|
252
|
+
return null
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Check if a peak is tonal based on surrounding bins
|
|
257
|
+
*/
|
|
258
|
+
function isTonal(psd, k, offsets) {
|
|
259
|
+
const TONAL_THRESHOLD = 7 // dB
|
|
260
|
+
|
|
261
|
+
for (const offset of offsets) {
|
|
262
|
+
const idx = k + offset
|
|
263
|
+
if (idx >= 0 && idx < psd.length) {
|
|
264
|
+
if (psd[k] - psd[idx] < TONAL_THRESHOLD) return false
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return true
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Mark component and neighbors as tonal/irrelevant
|
|
273
|
+
*/
|
|
274
|
+
function markTonalComponent(flags, k, searchOffsets) {
|
|
275
|
+
flags[k] = PSYMODEL_TONAL
|
|
276
|
+
|
|
277
|
+
const allOffsets = [...searchOffsets, -1, 1]
|
|
278
|
+
for (const offset of allOffsets) {
|
|
279
|
+
const idx = k + offset
|
|
280
|
+
if (idx >= 0 && idx < flags.length && idx !== k) {
|
|
281
|
+
flags[idx] = PSYMODEL_IRRELEVANT
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Detect non-tonal maskers (noise-like components)
|
|
288
|
+
* @param {Uint8Array} flags - Component classification flags
|
|
289
|
+
* @param {Float32Array} psd - Power spectral density
|
|
290
|
+
* @returns {Object} Updated flags and non-tonal masker list
|
|
291
|
+
*/
|
|
292
|
+
function findNonTonalMaskers(flags, psd) {
|
|
293
|
+
const nonTonalList = []
|
|
294
|
+
|
|
295
|
+
for (let i = 0; i < PSYMODEL_CRITICAL_BANDS.length - 1; i++) {
|
|
296
|
+
const cbStart = PSYMODEL_THRESHOLD_TABLE[PSYMODEL_CRITICAL_BANDS[i]][0] - 1
|
|
297
|
+
const cbEnd =
|
|
298
|
+
PSYMODEL_THRESHOLD_TABLE[PSYMODEL_CRITICAL_BANDS[i + 1]][0] - 1
|
|
299
|
+
|
|
300
|
+
// Calculate band energy and centroid
|
|
301
|
+
const bandAnalysis = analyzeCriticalBand(
|
|
302
|
+
psd,
|
|
303
|
+
flags,
|
|
304
|
+
cbStart,
|
|
305
|
+
cbEnd,
|
|
306
|
+
PSYMODEL_CRITICAL_BANDS[i]
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
if (bandAnalysis.powerDb > PSYMODEL_MIN_POWER_DB) {
|
|
310
|
+
// Place masker at energy centroid
|
|
311
|
+
let index = Math.round(cbStart + bandAnalysis.centroidOffset)
|
|
312
|
+
index = Math.max(0, Math.min(index, flags.length - 1))
|
|
313
|
+
|
|
314
|
+
// Avoid conflict with tonal maskers
|
|
315
|
+
if (flags[index] === PSYMODEL_TONAL) {
|
|
316
|
+
index = Math.min(index + 1, flags.length - 1)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
nonTonalList.push({ index, spl: bandAnalysis.powerDb })
|
|
320
|
+
flags[index] = PSYMODEL_NON_TONAL
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return { flags, nonTonalList }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Analyze a critical band for non-tonal masking
|
|
329
|
+
*/
|
|
330
|
+
function analyzeCriticalBand(psd, flags, startBin, endBin, bandIndex) {
|
|
331
|
+
let totalPower = 0
|
|
332
|
+
let weightedPower = 0
|
|
333
|
+
const baseBark = PSYMODEL_THRESHOLD_TABLE[bandIndex][1]
|
|
334
|
+
|
|
335
|
+
for (let k = startBin; k < endBin && k < flags.length; k++) {
|
|
336
|
+
if (flags[k] === PSYMODEL_NOT_EXAMINED) {
|
|
337
|
+
const power = fromDb(psd[k])
|
|
338
|
+
totalPower += power
|
|
339
|
+
|
|
340
|
+
const barkDiff =
|
|
341
|
+
PSYMODEL_THRESHOLD_TABLE[PSYMODEL_FREQ_TO_CB_MAP[k]][1] - baseBark
|
|
342
|
+
weightedPower += power * barkDiff
|
|
343
|
+
|
|
344
|
+
flags[k] = PSYMODEL_IRRELEVANT
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
powerDb: toDb(totalPower),
|
|
350
|
+
centroidOffset:
|
|
351
|
+
totalPower > 0 ? (weightedPower / totalPower) * (endBin - startBin) : 0,
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Remove weak maskers below absolute threshold
|
|
357
|
+
* @param {Uint8Array} flags - Component classification flags
|
|
358
|
+
* @param {Array} tonalList - List of tonal maskers
|
|
359
|
+
* @param {Array} nonTonalList - List of non-tonal maskers
|
|
360
|
+
* @returns {Object} Filtered masker lists
|
|
361
|
+
*/
|
|
362
|
+
function decimateMaskers(flags, tonalList, nonTonalList) {
|
|
363
|
+
const isAudible = (masker) => {
|
|
364
|
+
const k = masker.index
|
|
365
|
+
return (
|
|
366
|
+
k < PSYMODEL_FREQ_TO_CB_MAP.length &&
|
|
367
|
+
masker.spl >= PSYMODEL_THRESHOLD_TABLE[PSYMODEL_FREQ_TO_CB_MAP[k]][2]
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const markInaudible = (masker) => {
|
|
372
|
+
if (masker.index < flags.length) {
|
|
373
|
+
flags[masker.index] = PSYMODEL_IRRELEVANT
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Filter tonal maskers
|
|
378
|
+
const audibleTonal = []
|
|
379
|
+
for (const tonal of tonalList) {
|
|
380
|
+
if (isAudible(tonal)) {
|
|
381
|
+
audibleTonal.push(tonal)
|
|
382
|
+
} else {
|
|
383
|
+
markInaudible(tonal)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Filter non-tonal maskers
|
|
388
|
+
const audibleNonTonal = []
|
|
389
|
+
for (const nonTonal of nonTonalList) {
|
|
390
|
+
if (isAudible(nonTonal)) {
|
|
391
|
+
audibleNonTonal.push(nonTonal)
|
|
392
|
+
} else {
|
|
393
|
+
markInaudible(nonTonal)
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
tonalList: audibleTonal,
|
|
399
|
+
nonTonalList: audibleNonTonal,
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Calculate global masking threshold
|
|
405
|
+
* @param {Float32Array} psd - Power spectral density
|
|
406
|
+
* @param {Array} tonalMaskers - List of tonal maskers
|
|
407
|
+
* @param {Array} nonTonalMaskers - List of non-tonal maskers
|
|
408
|
+
* @param {Float32Array} thresholdBuffer - Output buffer for threshold
|
|
409
|
+
* @param {number} fftSize - FFT size
|
|
410
|
+
* @returns {Float32Array} Global masking threshold
|
|
411
|
+
*/
|
|
412
|
+
function calculateGlobalThreshold(
|
|
413
|
+
psd,
|
|
414
|
+
tonalMaskers,
|
|
415
|
+
nonTonalMaskers,
|
|
416
|
+
thresholdBuffer,
|
|
417
|
+
fftSize
|
|
418
|
+
) {
|
|
419
|
+
const criticalBandThresholds = new Float32Array(
|
|
420
|
+
PSYMODEL_CRITICAL_BANDS.length
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
// Calculate threshold for each critical band
|
|
424
|
+
calculateCriticalBandThresholds(
|
|
425
|
+
psd,
|
|
426
|
+
tonalMaskers,
|
|
427
|
+
nonTonalMaskers,
|
|
428
|
+
criticalBandThresholds
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
// Interpolate to full spectrum with compensation
|
|
432
|
+
interpolateThresholdCompensated(
|
|
433
|
+
criticalBandThresholds,
|
|
434
|
+
thresholdBuffer,
|
|
435
|
+
fftSize
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
return thresholdBuffer
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Calculate masking threshold for each critical band
|
|
443
|
+
*/
|
|
444
|
+
function calculateCriticalBandThresholds(
|
|
445
|
+
psd,
|
|
446
|
+
tonalMaskers,
|
|
447
|
+
nonTonalMaskers,
|
|
448
|
+
output
|
|
449
|
+
) {
|
|
450
|
+
for (let i = 0; i < PSYMODEL_CRITICAL_BANDS.length; i++) {
|
|
451
|
+
const bandData = PSYMODEL_THRESHOLD_TABLE[PSYMODEL_CRITICAL_BANDS[i]]
|
|
452
|
+
const maskedBark = bandData[1]
|
|
453
|
+
const quietThreshold = bandData[2]
|
|
454
|
+
|
|
455
|
+
// Start with threshold in quiet
|
|
456
|
+
let summedEnergy = fromDb(quietThreshold)
|
|
457
|
+
|
|
458
|
+
// Add contributions from all maskers
|
|
459
|
+
summedEnergy += calculateMaskerContributions(
|
|
460
|
+
psd,
|
|
461
|
+
tonalMaskers,
|
|
462
|
+
maskedBark,
|
|
463
|
+
true // isTonal
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
summedEnergy += calculateMaskerContributions(
|
|
467
|
+
psd,
|
|
468
|
+
nonTonalMaskers,
|
|
469
|
+
maskedBark,
|
|
470
|
+
false // isTonal
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
output[i] = toDb(summedEnergy)
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Calculate energy contributions from a set of maskers
|
|
479
|
+
*/
|
|
480
|
+
function calculateMaskerContributions(psd, maskers, maskedBark, isTonal) {
|
|
481
|
+
let totalEnergy = 0
|
|
482
|
+
|
|
483
|
+
for (const masker of maskers) {
|
|
484
|
+
const j = masker.index
|
|
485
|
+
if (j >= PSYMODEL_FREQ_TO_CB_MAP.length) continue
|
|
486
|
+
|
|
487
|
+
const maskerBark = PSYMODEL_THRESHOLD_TABLE[PSYMODEL_FREQ_TO_CB_MAP[j]][1]
|
|
488
|
+
const barkDistance = maskedBark - maskerBark
|
|
489
|
+
|
|
490
|
+
// Check if within masking range
|
|
491
|
+
if (barkDistance < -3 || barkDistance >= 8) continue
|
|
492
|
+
|
|
493
|
+
// Calculate masking level
|
|
494
|
+
const maskedDb = calculateMaskingLevel(
|
|
495
|
+
masker.spl,
|
|
496
|
+
psd[j],
|
|
497
|
+
maskerBark,
|
|
498
|
+
barkDistance,
|
|
499
|
+
isTonal
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
totalEnergy += fromDb(maskedDb)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return totalEnergy
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Calculate masking level based on masker properties
|
|
510
|
+
*/
|
|
511
|
+
function calculateMaskingLevel(
|
|
512
|
+
maskerSpl,
|
|
513
|
+
maskerPsd,
|
|
514
|
+
maskerBark,
|
|
515
|
+
barkDistance,
|
|
516
|
+
isTonal
|
|
517
|
+
) {
|
|
518
|
+
// Masking index
|
|
519
|
+
const tonalOffset = isTonal
|
|
520
|
+
? -1.525 - 0.275 * maskerBark - 4.5
|
|
521
|
+
: -1.525 - 0.175 * maskerBark - 0.5
|
|
522
|
+
|
|
523
|
+
// PSD-dependent factors
|
|
524
|
+
const psdFactor1 = 0.4 * maskerPsd + 6
|
|
525
|
+
const psdFactor2 = 0.15 * maskerPsd
|
|
526
|
+
|
|
527
|
+
// Calculate masking function
|
|
528
|
+
let maskingFunction
|
|
529
|
+
if (barkDistance < -1) {
|
|
530
|
+
maskingFunction = 17 * (barkDistance + 1) - psdFactor1
|
|
531
|
+
} else if (barkDistance < 0) {
|
|
532
|
+
maskingFunction = psdFactor1 * barkDistance
|
|
533
|
+
} else if (barkDistance < 1) {
|
|
534
|
+
maskingFunction = -17 * barkDistance
|
|
535
|
+
} else {
|
|
536
|
+
maskingFunction = -(barkDistance - 1) * (17 - psdFactor2) - 17
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return maskerSpl + tonalOffset + maskingFunction
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Interpolate critical band thresholds to full spectrum
|
|
544
|
+
* @param {Float32Array} cbThresholds - Critical band thresholds
|
|
545
|
+
* @param {Float32Array} output - Output buffer
|
|
546
|
+
* @param {number} fftSize - FFT size
|
|
547
|
+
*/
|
|
548
|
+
function interpolateThresholdCompensated(cbThresholds, output, fftSize) {
|
|
549
|
+
const COMPENSATION_FACTOR = 0.25
|
|
550
|
+
const halfSize = fftSize / 2
|
|
551
|
+
|
|
552
|
+
// Get frequency indices for critical bands
|
|
553
|
+
const freqIndices = PSYMODEL_CB_FREQ_INDICES
|
|
554
|
+
|
|
555
|
+
let bandIndex = 0
|
|
556
|
+
for (let i = 0; i <= halfSize; i++) {
|
|
557
|
+
if (i <= freqIndices[0]) {
|
|
558
|
+
// Before first critical band
|
|
559
|
+
output[i] = cbThresholds[0]
|
|
560
|
+
} else if (i >= freqIndices[freqIndices.length - 1]) {
|
|
561
|
+
// After last critical band
|
|
562
|
+
output[i] = cbThresholds[cbThresholds.length - 1]
|
|
563
|
+
} else {
|
|
564
|
+
// Find current band
|
|
565
|
+
while (
|
|
566
|
+
bandIndex < freqIndices.length - 1 &&
|
|
567
|
+
freqIndices[bandIndex + 1] < i
|
|
568
|
+
) {
|
|
569
|
+
bandIndex++
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Interpolate with compensation
|
|
573
|
+
const x0 = freqIndices[bandIndex]
|
|
574
|
+
const x1 = freqIndices[bandIndex + 1]
|
|
575
|
+
const y0 = cbThresholds[bandIndex]
|
|
576
|
+
const y1 = cbThresholds[bandIndex + 1]
|
|
577
|
+
const bandWidth = x1 - x0
|
|
578
|
+
|
|
579
|
+
if (bandWidth > 0) {
|
|
580
|
+
const t = (i - x0) / bandWidth
|
|
581
|
+
const interpolated = y0 + (y1 - y0) * t
|
|
582
|
+
const compensation = Math.abs(y1 - y0) * COMPENSATION_FACTOR
|
|
583
|
+
output[i] = interpolated + compensation
|
|
584
|
+
} else {
|
|
585
|
+
output[i] = y0
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|