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.
@@ -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
+ }