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,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
+ }