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,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carta1 Audio Codec - Transient Detection Module
|
|
3
|
+
*
|
|
4
|
+
* Detects transients using spectral flux analysis to prevent pre-echo artifacts.
|
|
5
|
+
* Combines multiple spectral features to identify sudden changes in signal
|
|
6
|
+
* characteristics that require special handling during encoding.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { FFT } from '../transforms/fft.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Perform FFT on time-domain samples and return magnitude spectrum
|
|
13
|
+
* @param {Float32Array} samples - Time-domain samples
|
|
14
|
+
* @param {number} fftSize - FFT size (must be power of 2)
|
|
15
|
+
* @returns {Float32Array} Magnitude spectrum (positive frequencies only)
|
|
16
|
+
*/
|
|
17
|
+
export function performFFT(samples, fftSize) {
|
|
18
|
+
const real = new Float32Array(fftSize)
|
|
19
|
+
const imag = new Float32Array(fftSize)
|
|
20
|
+
|
|
21
|
+
// Copy samples with zero-padding if necessary
|
|
22
|
+
const copyLen = Math.min(samples.length, fftSize)
|
|
23
|
+
real.set(samples.subarray(0, copyLen))
|
|
24
|
+
|
|
25
|
+
// Perform FFT
|
|
26
|
+
FFT.fft(real, imag)
|
|
27
|
+
|
|
28
|
+
// Calculate magnitude spectrum for positive frequencies
|
|
29
|
+
const magnitude = new Float32Array(fftSize / 2)
|
|
30
|
+
for (let i = 0; i < fftSize / 2; i++) {
|
|
31
|
+
magnitude[i] = Math.sqrt(real[i] * real[i] + imag[i] * imag[i])
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return magnitude
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Detect transients by analyzing spectral changes between frames
|
|
39
|
+
* @param {Float32Array} currentCoeffs - Current frame coefficients
|
|
40
|
+
* @param {Float32Array} prevCoeffs - Previous frame coefficients
|
|
41
|
+
* @param {number} threshold - Detection threshold
|
|
42
|
+
* @returns {boolean} True if transient detected
|
|
43
|
+
*/
|
|
44
|
+
export function detectTransient(currentCoeffs, prevCoeffs, threshold) {
|
|
45
|
+
// Can't detect transients without previous frame
|
|
46
|
+
if (!prevCoeffs) return false
|
|
47
|
+
|
|
48
|
+
// Calculate all spectral features
|
|
49
|
+
const features = calculateSpectralFeatures(currentCoeffs, prevCoeffs)
|
|
50
|
+
|
|
51
|
+
// Combine features using perceptually-motivated weighting
|
|
52
|
+
const transientScore = calculateTransientScore(features)
|
|
53
|
+
|
|
54
|
+
return transientScore > threshold
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Calculate all spectral features for transient detection
|
|
59
|
+
* @param {Float32Array} currentCoeffs - Current frame coefficients
|
|
60
|
+
* @param {Float32Array} prevCoeffs - Previous frame coefficients
|
|
61
|
+
* @returns {Object} Computed spectral features
|
|
62
|
+
*/
|
|
63
|
+
function calculateSpectralFeatures(currentCoeffs, prevCoeffs) {
|
|
64
|
+
// Calculate spectral flux (positive differences only)
|
|
65
|
+
const spectralFlux = calculateSpectralFlux(currentCoeffs, prevCoeffs)
|
|
66
|
+
|
|
67
|
+
// Calculate spectral flatness for both frames
|
|
68
|
+
const currentFlatness = calculateSpectralFlatness(currentCoeffs)
|
|
69
|
+
const prevFlatness = calculateSpectralFlatness(prevCoeffs)
|
|
70
|
+
const flatnessChange = Math.abs(currentFlatness - prevFlatness)
|
|
71
|
+
|
|
72
|
+
// Calculate high-frequency content ratio
|
|
73
|
+
const currentHfRatio = calculateHighFrequencyRatio(currentCoeffs)
|
|
74
|
+
const prevHfRatio = calculateHighFrequencyRatio(prevCoeffs)
|
|
75
|
+
const hfChange = Math.abs(currentHfRatio - prevHfRatio)
|
|
76
|
+
|
|
77
|
+
// Calculate energy change in dB
|
|
78
|
+
const energyChange = calculateEnergyChange(currentCoeffs, prevCoeffs)
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
spectralFlux,
|
|
82
|
+
flatnessChange,
|
|
83
|
+
hfChange,
|
|
84
|
+
energyChange,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Calculate normalized spectral flux (positive spectral differences)
|
|
90
|
+
* High values indicate sudden onset of new spectral components
|
|
91
|
+
*/
|
|
92
|
+
function calculateSpectralFlux(currentCoeffs, prevCoeffs) {
|
|
93
|
+
let flux = 0
|
|
94
|
+
let currentEnergy = 0
|
|
95
|
+
|
|
96
|
+
for (let i = 0; i < currentCoeffs.length; i++) {
|
|
97
|
+
const currentMag = Math.abs(currentCoeffs[i])
|
|
98
|
+
const prevMag = Math.abs(prevCoeffs[i])
|
|
99
|
+
|
|
100
|
+
// Only count positive differences (new energy)
|
|
101
|
+
const diff = currentMag - prevMag
|
|
102
|
+
if (diff > 0) {
|
|
103
|
+
flux += diff
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
currentEnergy += currentMag * currentMag
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Normalize by current frame energy to make threshold independent of level
|
|
110
|
+
const normalization = Math.sqrt(currentEnergy) || 1e-6
|
|
111
|
+
return flux / normalization
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Calculate spectral flatness (geometric mean / arithmetic mean)
|
|
116
|
+
* Measures how noise-like vs tonal the spectrum is
|
|
117
|
+
* @param {Float32Array} coeffs - Spectral coefficients
|
|
118
|
+
* @returns {number} Flatness measure (0 = tonal, 1 = noise-like)
|
|
119
|
+
*/
|
|
120
|
+
function calculateSpectralFlatness(coeffs) {
|
|
121
|
+
const EPSILON = 1e-10
|
|
122
|
+
let sumLog = 0
|
|
123
|
+
let sumLinear = 0
|
|
124
|
+
let validBins = 0
|
|
125
|
+
|
|
126
|
+
for (let i = 0; i < coeffs.length; i++) {
|
|
127
|
+
const magnitude = Math.abs(coeffs[i])
|
|
128
|
+
if (magnitude > EPSILON) {
|
|
129
|
+
sumLog += Math.log(magnitude)
|
|
130
|
+
sumLinear += magnitude
|
|
131
|
+
validBins++
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (validBins === 0) return 0
|
|
136
|
+
|
|
137
|
+
const geometricMean = Math.exp(sumLog / validBins)
|
|
138
|
+
const arithmeticMean = sumLinear / validBins
|
|
139
|
+
|
|
140
|
+
return arithmeticMean > EPSILON ? geometricMean / arithmeticMean : 0
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Calculate ratio of high-frequency to total energy
|
|
145
|
+
* High values indicate bright/harsh sounds that are perceptually important
|
|
146
|
+
* @param {Float32Array} coeffs - Spectral coefficients
|
|
147
|
+
* @returns {number} High-frequency energy ratio (0-1)
|
|
148
|
+
*/
|
|
149
|
+
function calculateHighFrequencyRatio(coeffs) {
|
|
150
|
+
const midPoint = Math.floor(coeffs.length / 2)
|
|
151
|
+
let lowEnergy = 0
|
|
152
|
+
let highEnergy = 0
|
|
153
|
+
|
|
154
|
+
for (let i = 0; i < midPoint; i++) {
|
|
155
|
+
lowEnergy += coeffs[i] * coeffs[i]
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (let i = midPoint; i < coeffs.length; i++) {
|
|
159
|
+
highEnergy += coeffs[i] * coeffs[i]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const totalEnergy = lowEnergy + highEnergy
|
|
163
|
+
return totalEnergy > 0 ? highEnergy / totalEnergy : 0
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Calculate energy change between frames in dB
|
|
168
|
+
* @param {Float32Array} currentCoeffs - Current frame coefficients
|
|
169
|
+
* @param {Float32Array} prevCoeffs - Previous frame coefficients
|
|
170
|
+
* @returns {number} Energy change in dB (positive values only)
|
|
171
|
+
*/
|
|
172
|
+
function calculateEnergyChange(currentCoeffs, prevCoeffs) {
|
|
173
|
+
let currentEnergy = 0
|
|
174
|
+
let prevEnergy = 0
|
|
175
|
+
|
|
176
|
+
for (let i = 0; i < currentCoeffs.length; i++) {
|
|
177
|
+
currentEnergy += currentCoeffs[i] * currentCoeffs[i]
|
|
178
|
+
prevEnergy += prevCoeffs[i] * prevCoeffs[i]
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Avoid log of zero and ensure minimum energy
|
|
182
|
+
currentEnergy = Math.max(currentEnergy, 1e-10)
|
|
183
|
+
prevEnergy = Math.max(prevEnergy, 1e-10)
|
|
184
|
+
|
|
185
|
+
const changeDb = 10 * Math.log10(currentEnergy / prevEnergy)
|
|
186
|
+
|
|
187
|
+
// Only interested in energy increases (potential transients)
|
|
188
|
+
return Math.max(0, changeDb)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Calculate unified transient score from spectral features
|
|
193
|
+
* Uses perceptually-motivated combination instead of magic numbers
|
|
194
|
+
* @param {Object} features - Spectral features
|
|
195
|
+
* @returns {number} Transient score
|
|
196
|
+
*/
|
|
197
|
+
function calculateTransientScore(features) {
|
|
198
|
+
const { spectralFlux, flatnessChange, hfChange, energyChange } = features
|
|
199
|
+
|
|
200
|
+
// Spectral flux is the primary indicator - it directly measures
|
|
201
|
+
// sudden onset of new frequency components
|
|
202
|
+
const fluxContribution = spectralFlux
|
|
203
|
+
|
|
204
|
+
// Flatness change indicates transition between tonal and noise-like
|
|
205
|
+
// signals (e.g., pitched to unpitched percussion)
|
|
206
|
+
// Scale by sqrt to reduce dominance
|
|
207
|
+
const flatnessContribution = Math.sqrt(flatnessChange)
|
|
208
|
+
|
|
209
|
+
// High-frequency changes are perceptually important but shouldn't
|
|
210
|
+
// dominate the decision. Use log scaling for perceptual relevance
|
|
211
|
+
const hfContribution = Math.log1p(hfChange * 10) / Math.log1p(10)
|
|
212
|
+
|
|
213
|
+
// Energy changes in dB are already perceptually scaled
|
|
214
|
+
// Normalize to 0-1 range assuming 30dB is a strong transient
|
|
215
|
+
const energyContribution = Math.min(energyChange / 30, 1)
|
|
216
|
+
|
|
217
|
+
// Combine features with equal perceptual importance
|
|
218
|
+
// Each contribution is normalized to roughly 0-1 range
|
|
219
|
+
return (
|
|
220
|
+
(fluxContribution +
|
|
221
|
+
flatnessContribution +
|
|
222
|
+
hfContribution +
|
|
223
|
+
energyContribution) /
|
|
224
|
+
4
|
|
225
|
+
)
|
|
226
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carta1 Worker Interface - Client-side API
|
|
3
|
+
*
|
|
4
|
+
* Provides a clean async interface for encoding and decoding ATRAC1 audio
|
|
5
|
+
* using Web Workers for non-blocking operation.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const codec = new Carta1Worker('carta1-worker.min.js')
|
|
9
|
+
* const result = await codec.encode(pcmData, options)
|
|
10
|
+
* codec.terminate()
|
|
11
|
+
*
|
|
12
|
+
* The worker path can be customized to match your deployment:
|
|
13
|
+
* new Carta1Worker('/js/carta1-worker.min.js')
|
|
14
|
+
* new Carta1Worker('https://cdn.example.com/carta1-worker.min.js')
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
class Carta1Worker {
|
|
18
|
+
/**
|
|
19
|
+
* Create a new ATRAC1 codec worker
|
|
20
|
+
*
|
|
21
|
+
* @param {string} [workerPath='atrac1-worker.min.js'] - Path to the worker script
|
|
22
|
+
*/
|
|
23
|
+
constructor(workerPath = 'carta1-worker.min.js') {
|
|
24
|
+
this.worker = new Worker(workerPath)
|
|
25
|
+
this.nextJobId = 1
|
|
26
|
+
this.jobs = new Map()
|
|
27
|
+
|
|
28
|
+
// Handle worker responses
|
|
29
|
+
this.worker.onmessage = (e) => {
|
|
30
|
+
const { jobId, error, result } = e.data
|
|
31
|
+
|
|
32
|
+
if (!jobId) {
|
|
33
|
+
console.warn('Received message without jobId:', e.data)
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (this.jobs.has(jobId)) {
|
|
38
|
+
const { resolve, reject } = this.jobs.get(jobId)
|
|
39
|
+
this.jobs.delete(jobId)
|
|
40
|
+
|
|
41
|
+
if (error) {
|
|
42
|
+
reject(new Error(error))
|
|
43
|
+
} else if (result) {
|
|
44
|
+
resolve(result)
|
|
45
|
+
} else {
|
|
46
|
+
reject(new Error('Worker returned no result'))
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
console.warn('Received result for unknown job:', jobId)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Handle worker errors
|
|
54
|
+
this.worker.onerror = (error) => {
|
|
55
|
+
console.error('Worker crashed:', error)
|
|
56
|
+
// Reject all pending jobs
|
|
57
|
+
for (const [, { reject }] of this.jobs) {
|
|
58
|
+
reject(new Error('Worker crashed: ' + error.message))
|
|
59
|
+
}
|
|
60
|
+
this.jobs.clear()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Encode PCM audio to ATRAC1 format
|
|
66
|
+
*
|
|
67
|
+
* @param {Float32Array|Array<Float32Array>} pcmData - PCM audio data
|
|
68
|
+
* - Mono: Float32Array
|
|
69
|
+
* - Stereo: [left, right] Float32Arrays
|
|
70
|
+
* @param {Object} [options={}] - Encoder options
|
|
71
|
+
* @param {number} [options.transientThresholdLow] - Low band transient threshold
|
|
72
|
+
* @param {number} [options.transientThresholdMid] - Mid band transient threshold
|
|
73
|
+
* @param {number} [options.transientThresholdHigh] - High band transient threshold
|
|
74
|
+
* @param {number} [options.normalizationDb] - Normalization level in dB
|
|
75
|
+
* @returns {Promise<Object>} Encoding result
|
|
76
|
+
* @returns {Blob} returns.aeaBlob - ATRAC1 encoded audio blob
|
|
77
|
+
* @returns {Object} returns.shortBlockData - Short block frame indices
|
|
78
|
+
*/
|
|
79
|
+
async encode(pcmData, options = {}) {
|
|
80
|
+
const jobId = this.nextJobId++
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
this.jobs.set(jobId, { resolve, reject })
|
|
83
|
+
try {
|
|
84
|
+
// Ensure plain object for serialization
|
|
85
|
+
const plainOptions = { ...options }
|
|
86
|
+
this.worker.postMessage({
|
|
87
|
+
jobId,
|
|
88
|
+
type: 'encode',
|
|
89
|
+
pcmData,
|
|
90
|
+
options: plainOptions,
|
|
91
|
+
})
|
|
92
|
+
} catch (error) {
|
|
93
|
+
this.jobs.delete(jobId)
|
|
94
|
+
reject(error)
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Decode ATRAC1 audio to PCM WAV format
|
|
101
|
+
*
|
|
102
|
+
* @param {Object} aea - ATRAC1 audio data
|
|
103
|
+
* @param {Uint8Array} aea.header - AEA file header
|
|
104
|
+
* @param {Array} aea.aeaData - Encoded frame data
|
|
105
|
+
* @param {Object} [aea.info] - Optional metadata
|
|
106
|
+
* @returns {Promise<Object>} Decoding result
|
|
107
|
+
* @returns {Blob} returns.wavBlob - Decoded WAV audio blob
|
|
108
|
+
* @returns {Object} returns.info - Audio metadata (channels, sample rate)
|
|
109
|
+
*/
|
|
110
|
+
async decode(aea) {
|
|
111
|
+
const jobId = this.nextJobId++
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
this.jobs.set(jobId, { resolve, reject })
|
|
114
|
+
try {
|
|
115
|
+
this.worker.postMessage({ jobId, type: 'decode', aea })
|
|
116
|
+
} catch (error) {
|
|
117
|
+
this.jobs.delete(jobId)
|
|
118
|
+
reject(error)
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Parse AEA file blob to extract metadata and frame data
|
|
125
|
+
*
|
|
126
|
+
* @param {Blob} blob - AEA file blob
|
|
127
|
+
* @returns {Promise<Object>} Parsed AEA data
|
|
128
|
+
* @returns {Uint8Array} returns.header - AEA file header
|
|
129
|
+
* @returns {Array} returns.aeaData - Encoded frame data
|
|
130
|
+
* @returns {Object} returns.info - Metadata (title, channels, frames)
|
|
131
|
+
*/
|
|
132
|
+
async parseAeaBlob(blob) {
|
|
133
|
+
const jobId = this.nextJobId++
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
this.jobs.set(jobId, { resolve, reject })
|
|
136
|
+
try {
|
|
137
|
+
this.worker.postMessage({ jobId, type: 'parseAea', blob })
|
|
138
|
+
} catch (error) {
|
|
139
|
+
this.jobs.delete(jobId)
|
|
140
|
+
reject(error)
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Get default encoder options metadata
|
|
147
|
+
*
|
|
148
|
+
* @returns {Promise<Object>} Default encoder options
|
|
149
|
+
*/
|
|
150
|
+
async getEncoderOptions() {
|
|
151
|
+
const jobId = this.nextJobId++
|
|
152
|
+
return new Promise((resolve, reject) => {
|
|
153
|
+
this.jobs.set(jobId, { resolve, reject })
|
|
154
|
+
try {
|
|
155
|
+
this.worker.postMessage({ jobId, type: 'getEncoderOptions' })
|
|
156
|
+
} catch (error) {
|
|
157
|
+
this.jobs.delete(jobId)
|
|
158
|
+
reject(error)
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Terminate the worker and clean up resources
|
|
165
|
+
*
|
|
166
|
+
* Call this when done using the codec to free memory
|
|
167
|
+
*/
|
|
168
|
+
terminate() {
|
|
169
|
+
if (this.worker) {
|
|
170
|
+
this.worker.terminate()
|
|
171
|
+
this.worker = null
|
|
172
|
+
}
|
|
173
|
+
this.jobs.clear()
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Export for different module systems
|
|
178
|
+
/* global define, window */
|
|
179
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
180
|
+
module.exports = Carta1Worker
|
|
181
|
+
} else if (typeof define === 'function' && define.amd) {
|
|
182
|
+
define([], function () {
|
|
183
|
+
return Carta1Worker
|
|
184
|
+
})
|
|
185
|
+
} else if (typeof window !== 'undefined') {
|
|
186
|
+
// Global export for browser
|
|
187
|
+
window.Carta1Worker = Carta1Worker
|
|
188
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carta1 Web Worker - Self-contained audio codec worker
|
|
3
|
+
*
|
|
4
|
+
* Provides asynchronous encoding and decoding of ATRAC1 audio format
|
|
5
|
+
* in a separate thread to prevent blocking the main UI thread.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* Include this file as a web worker:
|
|
9
|
+
* new Worker('carta1-worker.min.js')
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { AeaFile } from '../io/serialization.js'
|
|
13
|
+
import { EncoderOptions } from '../core/options.js'
|
|
14
|
+
import { AudioProcessor } from '../io/processor.js'
|
|
15
|
+
import { SAMPLE_RATE } from '../core/constants.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Handle messages from the main thread
|
|
19
|
+
*
|
|
20
|
+
* Supported message types:
|
|
21
|
+
* - 'encode': Encode PCM to ATRAC1
|
|
22
|
+
* - 'decode': Decode ATRAC1 to PCM
|
|
23
|
+
* - 'parseAea': Parse AEA blob metadata
|
|
24
|
+
* - 'getEncoderOptions': Get default encoder options
|
|
25
|
+
*/
|
|
26
|
+
self.onmessage = async (e) => {
|
|
27
|
+
const { jobId, type, pcmData, aea, options, title, blob } = e.data
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
let result
|
|
31
|
+
|
|
32
|
+
switch (type) {
|
|
33
|
+
case 'encode':
|
|
34
|
+
result = await encodeAtrac(pcmData, options, title)
|
|
35
|
+
break
|
|
36
|
+
case 'decode':
|
|
37
|
+
result = await decodeAtrac(aea)
|
|
38
|
+
break
|
|
39
|
+
case 'parseAea':
|
|
40
|
+
result = await AudioProcessor.parseAeaBlob(blob)
|
|
41
|
+
break
|
|
42
|
+
case 'getEncoderOptions':
|
|
43
|
+
result = getEncoderOptionsMetadata()
|
|
44
|
+
break
|
|
45
|
+
default:
|
|
46
|
+
throw new Error(`Unknown worker message type: ${type}`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
self.postMessage({ jobId, result })
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.error('Worker error:', error)
|
|
52
|
+
self.postMessage({ jobId, error: error.message })
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Encode PCM audio data to ATRAC1 format
|
|
58
|
+
*
|
|
59
|
+
* @param {Array<Float32Array>} pcmData - PCM audio data [mono] or [left, right]
|
|
60
|
+
* @param {Object} options - Encoder options
|
|
61
|
+
* @returns {Promise<Object>} Encoded AEA blob
|
|
62
|
+
*/
|
|
63
|
+
async function encodeAtrac(pcmData, options, title) {
|
|
64
|
+
title = title ?? 'encoded by carta1'
|
|
65
|
+
try {
|
|
66
|
+
const audioFrames = AudioProcessor.frameBufferToFrames(pcmData)
|
|
67
|
+
const channelCount = pcmData.length
|
|
68
|
+
|
|
69
|
+
// Create encoder options from provided settings
|
|
70
|
+
const encoderOptions = new EncoderOptions(options)
|
|
71
|
+
|
|
72
|
+
const encodedFrames = AudioProcessor.encodeStream(audioFrames, {
|
|
73
|
+
channelCount,
|
|
74
|
+
encoderOptions,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const frameArray = await AudioProcessor.collectFrames(encodedFrames)
|
|
78
|
+
|
|
79
|
+
const aeaBlob = await AudioProcessor.createAeaBlob(frameArray, {
|
|
80
|
+
title,
|
|
81
|
+
channelCount,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// Return the blob
|
|
85
|
+
return { aeaBlob }
|
|
86
|
+
} catch (error) {
|
|
87
|
+
console.error('Worker encode error:', error)
|
|
88
|
+
throw error
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get default encoder options metadata
|
|
94
|
+
*
|
|
95
|
+
* @returns {Object} Default encoder options as plain object
|
|
96
|
+
*/
|
|
97
|
+
function getEncoderOptionsMetadata() {
|
|
98
|
+
const defaultOptions = new EncoderOptions()
|
|
99
|
+
return defaultOptions.toObject()
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Decode ATRAC1 data to PCM WAV format
|
|
104
|
+
*
|
|
105
|
+
* @param {Object} aea - AEA data object
|
|
106
|
+
* @param {Uint8Array} aea.header - AEA file header
|
|
107
|
+
* @param {Array} aea.aeaData - Serialized frame data
|
|
108
|
+
* @param {Object} [aea.info] - Optional metadata
|
|
109
|
+
* @returns {Promise<Object>} WAV blob and decoding info
|
|
110
|
+
*/
|
|
111
|
+
async function decodeAtrac(aea) {
|
|
112
|
+
const { header, aeaData, info } = aea
|
|
113
|
+
|
|
114
|
+
// Use provided info, or fall back to header parsing, or defaults
|
|
115
|
+
let decodingInfo
|
|
116
|
+
if (info) {
|
|
117
|
+
decodingInfo = { sampleRate: SAMPLE_RATE, ...info }
|
|
118
|
+
} else if (header) {
|
|
119
|
+
const headerInfo = AeaFile.parseHeader(header)
|
|
120
|
+
decodingInfo = { sampleRate: SAMPLE_RATE, ...headerInfo }
|
|
121
|
+
} else {
|
|
122
|
+
decodingInfo = { channelCount: 2, sampleRate: SAMPLE_RATE }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const encodedFrameStream = AudioProcessor.deserializedFrameStream(aeaData)
|
|
126
|
+
const decodedFrames = AudioProcessor.decodeStream(encodedFrameStream, {
|
|
127
|
+
channelCount: decodingInfo.channelCount,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const pcmFrames = await AudioProcessor.collectFrames(decodedFrames)
|
|
131
|
+
|
|
132
|
+
// Create WAV blob directly
|
|
133
|
+
const wavBlob = AudioProcessor.createWavBlob(
|
|
134
|
+
pcmFrames,
|
|
135
|
+
decodingInfo.channelCount,
|
|
136
|
+
decodingInfo.sampleRate
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
return { wavBlob, info: decodingInfo }
|
|
140
|
+
}
|