@effetune/dsp 0.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,309 @@
1
+ import { DSP_PARAM_PACKERS } from './internal/dsp-params.generated.js';
2
+ import { getEffectDefinition, getEffectImplementation } from './catalog.js';
3
+ import { Effect, validateChannel, validateParameterValue } from './effect.js';
4
+ import { AssetError, EffeTuneError, EffectError, ValidationError } from './errors.js';
5
+
6
+ const DOCUMENT_KEYS = new Set(['version', 'chain']);
7
+ const EFFECT_KEYS = new Set(['id', 'type', 'enabled', 'channel', 'parameters', 'assets']);
8
+ const STREAM_RECONFIGURATION_PARAMETERS = new Map([
9
+ ['FIRCrossover', new Set(['bandCount', 'latencyMode', 'filterDelaySamples'])],
10
+ ['FiveBandFIRPEQ', new Set(['latencyMode', 'filterDelaySamples'])],
11
+ ['GroupDelayEQ', new Set(['latencyMode', 'filterDelaySamples'])],
12
+ ['IRReverb', new Set(['channelMode', 'latency', 'convolutionRate'])],
13
+ ['RoomEQ', new Set(['latencyMode', 'filterDelaySamples'])]
14
+ ]);
15
+
16
+ // App preset envelopes are recognized here rather than in the preset parser so that
17
+ // every entry point into the semantic chain document (parsePreset, createChain and
18
+ // the AudioWorklet loader) reports the same guidance instead of a generic
19
+ // "unsupported field" complaint. The Python binding keeps the same detection in
20
+ // effetune.presets.load_preset_document.
21
+ const LEGACY_ENVELOPE_KEYS_V1 = ['pipeline', 'plugins'];
22
+
23
+ function isRecord(value) {
24
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
25
+ }
26
+
27
+ function rejectAppPresetEnvelope(input) {
28
+ if (!isRecord(input)) return;
29
+ const envelopes = LEGACY_ENVELOPE_KEYS_V1.filter(key => Object.hasOwn(input, key));
30
+ if (envelopes.length === 0) return;
31
+ throw new ValidationError(
32
+ `Preset contains the EffeTune app preset field(s): ${envelopes.join(', ')}; ` +
33
+ 'this is an app preset rather than a semantic chain document. ' +
34
+ 'Import it with importLegacyPreset().'
35
+ );
36
+ }
37
+
38
+ function cloneEffect(effect) {
39
+ return {
40
+ id: effect.id,
41
+ type: effect.type,
42
+ enabled: effect.enabled,
43
+ channel: effect.channel,
44
+ parameters: Object.fromEntries(
45
+ Object.entries(effect.parameters).map(([name, value]) => [
46
+ name,
47
+ Array.isArray(value) ? [...value] : value
48
+ ])
49
+ ),
50
+ ...(effect.assets === undefined ? {} : { assets: { ...effect.assets } })
51
+ };
52
+ }
53
+
54
+ function fromPlainEffect(value, index) {
55
+ if (!isRecord(value)) throw new ValidationError(`Chain entry ${index} must be an object.`);
56
+ for (const key of Object.keys(value)) {
57
+ if (!EFFECT_KEYS.has(key)) {
58
+ throw new ValidationError(`Chain entry ${index} has an unsupported field: ${key}`);
59
+ }
60
+ }
61
+ if (typeof value.type !== 'string') {
62
+ throw new ValidationError(`Chain entry ${index} requires an effect type.`);
63
+ }
64
+ if (!isRecord(value.parameters)) {
65
+ throw new ValidationError(`Chain entry ${index} requires a parameters object.`);
66
+ }
67
+ const definition = getEffectDefinition(value.type);
68
+ const id = value.id;
69
+ if (id !== undefined &&
70
+ (typeof id !== 'string' || id.length < 1 || id.length > 128)) {
71
+ throw new ValidationError(`Chain entry ${index} has an invalid effect id.`);
72
+ }
73
+ const enabled = value.enabled ?? true;
74
+ if (typeof enabled !== 'boolean') {
75
+ throw new ValidationError(`Chain entry ${index} enabled must be boolean.`);
76
+ }
77
+ const channel = validateChannel(value.channel ?? 'all');
78
+ const parameterByName = new Map(definition.parameters.map(parameter => [parameter.name, parameter]));
79
+ for (const key of Object.keys(value.parameters)) {
80
+ if (!parameterByName.has(key)) {
81
+ throw new ValidationError(`Unknown parameter ${value.type}.${key}.`);
82
+ }
83
+ }
84
+ const parameters = {};
85
+ for (const parameter of definition.parameters) {
86
+ const supplied = Object.hasOwn(value.parameters, parameter.name)
87
+ ? value.parameters[parameter.name]
88
+ : parameter.default;
89
+ parameters[parameter.name] = validateParameterValue(value.type, parameter, supplied);
90
+ }
91
+ const declaredAssets = definition.assets ?? [];
92
+ let assets;
93
+ if (declaredAssets.length === 0) {
94
+ if (value.assets !== undefined) throw new AssetError(`${value.type} does not accept external assets.`);
95
+ } else {
96
+ if (!isRecord(value.assets)) throw new AssetError(`${value.type} requires an assets object.`);
97
+ const allowed = new Set(declaredAssets.map(asset => asset.name));
98
+ for (const name of Object.keys(value.assets)) {
99
+ if (!allowed.has(name)) throw new AssetError(`${value.type} has no asset named ${name}.`);
100
+ }
101
+ assets = {};
102
+ for (const asset of declaredAssets) {
103
+ const reference = value.assets[asset.name];
104
+ if (asset.required && (typeof reference !== 'string' || reference.length < 1 || reference.length > 128)) {
105
+ throw new AssetError(`${value.type}.${asset.name} requires a non-empty asset reference.`);
106
+ }
107
+ if (reference !== undefined) assets[asset.name] = reference;
108
+ }
109
+ }
110
+ return { id, type: value.type, enabled, channel, parameters, ...(assets ? { assets } : {}) };
111
+ }
112
+
113
+ function assignIds(effects) {
114
+ const explicit = new Set();
115
+ for (const effect of effects) {
116
+ if (effect.id === undefined) continue;
117
+ if (explicit.has(effect.id)) {
118
+ throw new ValidationError(`Duplicate effect id: ${effect.id}`);
119
+ }
120
+ explicit.add(effect.id);
121
+ }
122
+ const counters = new Map();
123
+ return effects.map(effect => {
124
+ if (effect.id !== undefined) return effect;
125
+ let count = counters.get(effect.type) ?? 0;
126
+ let id;
127
+ do {
128
+ count += 1;
129
+ id = `${effect.type}#${count}`;
130
+ } while (explicit.has(id));
131
+ counters.set(effect.type, count);
132
+ explicit.add(id);
133
+ return { ...effect, id };
134
+ });
135
+ }
136
+
137
+ export function normalizeChainDocument(input) {
138
+ let entries;
139
+ if (Array.isArray(input)) {
140
+ entries = input;
141
+ } else {
142
+ rejectAppPresetEnvelope(input);
143
+ if (!isRecord(input)) throw new ValidationError('A chain must be an array or a v1 chain document.');
144
+ for (const key of Object.keys(input)) {
145
+ if (!DOCUMENT_KEYS.has(key)) throw new ValidationError(`Unsupported chain document field: ${key}`);
146
+ }
147
+ if (input.version !== 1) throw new ValidationError('Only chain document version 1 is supported.');
148
+ if (!Array.isArray(input.chain)) throw new ValidationError('Chain document chain must be an array.');
149
+ entries = input.chain;
150
+ }
151
+ const effects = entries.map((entry, index) =>
152
+ fromPlainEffect(entry instanceof Effect ? entry.toJSON() : entry, index)
153
+ );
154
+ return {
155
+ version: 1,
156
+ chain: assignIds(effects).map(cloneEffect)
157
+ };
158
+ }
159
+
160
+ export function validateSeed(seed = 0) {
161
+ if (!Number.isInteger(seed) || seed < 0 || seed > 0xffffffff) {
162
+ throw new ValidationError('seed must be an integer from 0 to 4294967295.');
163
+ }
164
+ return seed >>> 0;
165
+ }
166
+
167
+ export function validateSampleRate(sampleRate) {
168
+ if (!Number.isInteger(sampleRate) || sampleRate <= 0 || sampleRate > 0xffffffff) {
169
+ throw new ValidationError('sampleRate must be a positive 32-bit integer.');
170
+ }
171
+ return sampleRate;
172
+ }
173
+
174
+ export function validateEffectSampleRate(effect, sampleRate) {
175
+ const definition = getEffectDefinition(effect.type);
176
+ if (definition.sampleRates && !definition.sampleRates.includes(sampleRate)) {
177
+ throw new ValidationError(`${effect.type} does not support a sample rate of ${sampleRate} Hz.`);
178
+ }
179
+ }
180
+
181
+ function toInternalValue(transform, value) {
182
+ switch (transform?.kind ?? 'identity') {
183
+ case 'identity':
184
+ return value;
185
+ case 'naturalLog':
186
+ return Math.log(value);
187
+ case 'log10':
188
+ return Math.log10(value);
189
+ case 'decibelsFromReference':
190
+ return 20 * Math.log10(value / transform.reference);
191
+ case 'map': {
192
+ const mapping = transform.values.find(entry => Object.is(entry.public, value));
193
+ if (!mapping) throw new ValidationError('A semantic parameter mapping is unavailable.');
194
+ return mapping.internal;
195
+ }
196
+ default:
197
+ throw new EffectError(`Unsupported semantic transform: ${String(transform?.kind)}`);
198
+ }
199
+ }
200
+
201
+ export function packEffect(effect) {
202
+ const implementation = getEffectImplementation(effect.type);
203
+ const packer = DSP_PARAM_PACKERS.get(implementation.internalType);
204
+ if (!packer || (packer.hash >>> 0) !== (implementation.layoutHash >>> 0)) {
205
+ throw new EffectError(`${effect.type} is incompatible with the packaged DSP artifact.`);
206
+ }
207
+ const internal = {};
208
+ for (const mapping of implementation.packedParameters) {
209
+ const value = effect.parameters[mapping.publicName];
210
+ if (mapping.count === 1) {
211
+ internal[mapping.keys[0]] = toInternalValue(mapping.transform, value);
212
+ } else {
213
+ for (let index = 0; index < mapping.count; index++) {
214
+ internal[mapping.keys[index]] = toInternalValue(mapping.transform, value[index]);
215
+ }
216
+ }
217
+ }
218
+ const structured = implementation.structuredParameter;
219
+ if (structured) {
220
+ internal[structured.key] = effect.parameters[structured.publicName];
221
+ }
222
+ // The generated packer reports capacity limits with plain JavaScript errors.
223
+ // Translating them here keeps every public entry point (process, stream, setParam)
224
+ // inside the documented error taxonomy, matching the Python binding which already
225
+ // raises ValidationError for the same input.
226
+ let bytes;
227
+ if (structured) {
228
+ try {
229
+ bytes = packer.packBytes?.(internal);
230
+ } catch (error) {
231
+ if (error instanceof EffeTuneError) throw error;
232
+ const detail = error instanceof Error ? error.message : String(error);
233
+ throw new ValidationError(
234
+ `${effect.type}.${structured.publicName} cannot be packed: ${detail}`,
235
+ { cause: error }
236
+ );
237
+ }
238
+ }
239
+ if (structured && !(bytes instanceof Uint8Array)) {
240
+ throw new EffectError(`${effect.type} structured parameters are unavailable.`);
241
+ }
242
+ return {
243
+ internalType: implementation.internalType,
244
+ hash: implementation.layoutHash >>> 0,
245
+ values: packer.pack(internal),
246
+ ...(bytes ? { bytes } : {})
247
+ };
248
+ }
249
+
250
+ export function setEffectParameter(effect, parameterName, value) {
251
+ const definition = getEffectDefinition(effect.type);
252
+ const parameter = definition.parameters.find(entry => entry.name === parameterName);
253
+ if (!parameter) throw new ValidationError(`Unknown parameter ${effect.type}.${parameterName}.`);
254
+ const validated = validateParameterValue(effect.type, parameter, value);
255
+ return {
256
+ ...cloneEffect(effect),
257
+ parameters: { ...effect.parameters, [parameterName]: validated }
258
+ };
259
+ }
260
+
261
+ export function validateStreamParameterUpdate(effect, parameterName) {
262
+ if (STREAM_RECONFIGURATION_PARAMETERS.get(effect.type)?.has(parameterName)) {
263
+ throw new ValidationError(
264
+ `${effect.type}.${parameterName} cannot be updated while a stream is open; ` +
265
+ 'create a new stream with the updated effect.'
266
+ );
267
+ }
268
+ }
269
+
270
+ export function channelRange(channel, channelCount) {
271
+ validateChannel(channel);
272
+ if (!Number.isInteger(channelCount) || channelCount < 1 || channelCount > 8) {
273
+ throw new ValidationError('Audio must contain between 1 and 8 channels.');
274
+ }
275
+ if (channel === 'all') return { start: 0, count: channelCount };
276
+ if (channel === 'stereo') return { start: 0, count: channelCount >= 2 ? 2 : 1 };
277
+ if (channel === 'left') return { start: 0, count: 1 };
278
+ if (channel === 'right') {
279
+ if (channelCount < 2) throw new ValidationError('The right channel is unavailable in mono audio.');
280
+ return { start: 1, count: 1 };
281
+ }
282
+ if (channel === '34' || channel === '56' || channel === '78') {
283
+ const start = Number(channel[0]) - 1;
284
+ if (channelCount < start + 2) {
285
+ throw new ValidationError(`Channel pair ${channel} is unavailable in ${channelCount}-channel audio.`);
286
+ }
287
+ return { start, count: 2 };
288
+ }
289
+ const start = Number(channel) - 1;
290
+ if (channelCount <= start) {
291
+ throw new ValidationError(`Channel ${channel} is unavailable in ${channelCount}-channel audio.`);
292
+ }
293
+ return { start, count: 1 };
294
+ }
295
+
296
+ export function channelToEngine(channel) {
297
+ if (channel === 'all') return 'A';
298
+ if (channel === 'stereo') return null;
299
+ if (channel === 'left') return 'L';
300
+ if (channel === 'right') return 'R';
301
+ return channel;
302
+ }
303
+
304
+ export function requireResolvedAsset(effect, resolvedAssets) {
305
+ if (effect.assets === undefined) return null;
306
+ const asset = resolvedAssets?.get(effect.id)?.impulseResponse;
307
+ if (!asset) throw new AssetError(`${effect.id} is missing its impulseResponse asset.`);
308
+ return asset;
309
+ }
@@ -0,0 +1,322 @@
1
+ const HEADER_BYTES = 16;
2
+ const LEVEL_FRAME = 1;
3
+ const SCOPE_FRAME = 3;
4
+ const SPECTRUM_FRAME = 4;
5
+ const SPECTROGRAM_FRAME = 5;
6
+ const STEREO_FRAME = 6;
7
+
8
+ const ANALYZER_FRAMES = Object.freeze({
9
+ LevelMeter: [LEVEL_FRAME, 1],
10
+ Oscilloscope: [SCOPE_FRAME, 2],
11
+ SpectrumAnalyzer: [SPECTRUM_FRAME, 1],
12
+ Spectrogram: [SPECTROGRAM_FRAME, 1],
13
+ StereoMeter: [STEREO_FRAME, 2]
14
+ });
15
+
16
+ export const TELEMETRY_RING_BYTES = 256 * 1024;
17
+ export const TELEMETRY_RATE_HZ = 60;
18
+
19
+ export function supportsTelemetry(effectType) {
20
+ return Object.hasOwn(ANALYZER_FRAMES, effectType);
21
+ }
22
+
23
+ function common(node, kind, sequence, dropped) {
24
+ return {
25
+ kind,
26
+ effectType: node.effectType,
27
+ effectId: node.effectId,
28
+ effectIndex: node.effectIndex,
29
+ sequence,
30
+ dropped
31
+ };
32
+ }
33
+
34
+ function decodeLevel(payload, node, sequence, dropped) {
35
+ if (payload.byteLength < 16) return null;
36
+ const channelCount = payload.getUint32(0, true);
37
+ if (channelCount < 1 || channelCount > 8 || payload.byteLength !== 8 + channelCount * 8) {
38
+ return null;
39
+ }
40
+ const clipFlags = payload.getUint32(4 + channelCount * 8, true);
41
+ if ((clipFlags & ~((1 << channelCount) - 1)) !== 0) return null;
42
+ const channels = new Array(channelCount);
43
+ for (let channel = 0; channel < channelCount; channel++) {
44
+ const offset = 4 + channel * 8;
45
+ const peak = payload.getFloat32(offset, true);
46
+ const rms = payload.getFloat32(offset + 4, true);
47
+ if (!Number.isFinite(peak) || peak < 0 || !Number.isFinite(rms) || rms < 0) {
48
+ return null;
49
+ }
50
+ channels[channel] = { peak, rms, clipped: (clipFlags & (1 << channel)) !== 0 };
51
+ }
52
+ return { ...common(node, 'level', sequence, dropped), channels };
53
+ }
54
+
55
+ function decodeOscilloscope(payload, node, sequence, dropped) {
56
+ if (payload.byteLength < 20) return null;
57
+ const sampleRate = payload.getFloat32(0, true);
58
+ const captureSampleCount = payload.getUint32(4, true);
59
+ const triggerOffset = payload.getUint32(8, true);
60
+ const bucketCount = payload.getUint16(12, true);
61
+ const encoding = payload.getUint8(14);
62
+ const flags = payload.getUint8(15);
63
+ if (!Number.isFinite(sampleRate) || sampleRate <= 0 ||
64
+ captureSampleCount < 1 || captureSampleCount > 65536 ||
65
+ triggerOffset >= captureSampleCount || (flags & ~1) !== 0) {
66
+ return null;
67
+ }
68
+ if (encoding === 0) {
69
+ if (bucketCount !== 0 || captureSampleCount > 2048 ||
70
+ payload.byteLength !== 16 + captureSampleCount * 4) {
71
+ return null;
72
+ }
73
+ const sampleIndices = new Uint32Array(captureSampleCount);
74
+ const values = new Float32Array(captureSampleCount);
75
+ for (let index = 0; index < captureSampleCount; index++) {
76
+ const value = payload.getFloat32(16 + index * 4, true);
77
+ if (!Number.isFinite(value)) return null;
78
+ sampleIndices[index] = index;
79
+ values[index] = value;
80
+ }
81
+ return {
82
+ ...common(node, 'oscilloscope', sequence, dropped),
83
+ sampleRate,
84
+ captureSampleCount,
85
+ triggerOffset,
86
+ triggered: (flags & 1) !== 0,
87
+ encoding: 'samples',
88
+ sampleIndices,
89
+ values
90
+ };
91
+ }
92
+ if (encoding !== 1 || captureSampleCount <= 2048 || bucketCount !== 512 ||
93
+ payload.byteLength !== 16 + bucketCount * 18) {
94
+ return null;
95
+ }
96
+ const sampleIndices = new Uint32Array(bucketCount * 4);
97
+ const values = new Float32Array(bucketCount * 4);
98
+ let pointCount = 0;
99
+ const append = (sampleIndex, value) => {
100
+ if (pointCount > 0 && sampleIndices[pointCount - 1] === sampleIndex) {
101
+ return values[pointCount - 1] === value;
102
+ }
103
+ sampleIndices[pointCount] = sampleIndex;
104
+ values[pointCount] = value;
105
+ pointCount += 1;
106
+ return true;
107
+ };
108
+ for (let bucket = 0; bucket < bucketCount; bucket++) {
109
+ const begin = Math.floor(bucket * captureSampleCount / bucketCount);
110
+ const end = Math.floor((bucket + 1) * captureSampleCount / bucketCount);
111
+ const bucketLength = end - begin;
112
+ const offset = 16 + bucket * 18;
113
+ const first = payload.getFloat32(offset, true);
114
+ const minimum = payload.getFloat32(offset + 4, true);
115
+ const maximum = payload.getFloat32(offset + 8, true);
116
+ const last = payload.getFloat32(offset + 12, true);
117
+ const minimumOffset = payload.getUint8(offset + 16);
118
+ const maximumOffset = payload.getUint8(offset + 17);
119
+ if (!Number.isFinite(first) || !Number.isFinite(minimum) ||
120
+ !Number.isFinite(maximum) || !Number.isFinite(last) || minimum > maximum ||
121
+ first < minimum || first > maximum || last < minimum || last > maximum ||
122
+ minimumOffset >= bucketLength || maximumOffset >= bucketLength) {
123
+ return null;
124
+ }
125
+ const minimumIndex = begin + minimumOffset;
126
+ const maximumIndex = begin + maximumOffset;
127
+ if (!append(begin, first)) return null;
128
+ if (minimumIndex <= maximumIndex) {
129
+ if (!append(minimumIndex, minimum) || !append(maximumIndex, maximum)) return null;
130
+ } else if (!append(maximumIndex, maximum) || !append(minimumIndex, minimum)) {
131
+ return null;
132
+ }
133
+ if (!append(end - 1, last)) return null;
134
+ }
135
+ return {
136
+ ...common(node, 'oscilloscope', sequence, dropped),
137
+ sampleRate,
138
+ captureSampleCount,
139
+ triggerOffset,
140
+ triggered: (flags & 1) !== 0,
141
+ encoding: 'minMax',
142
+ sampleIndices: sampleIndices.slice(0, pointCount),
143
+ values: values.slice(0, pointCount)
144
+ };
145
+ }
146
+
147
+ function decodeSpectrum(payload, node, sequence, dropped) {
148
+ if (payload.byteLength < 28) return null;
149
+ const sampleRate = payload.getFloat32(0, true);
150
+ const binCount = payload.getUint32(4, true);
151
+ const points = payload.getUint16(8, true);
152
+ const flags = payload.getUint16(10, true);
153
+ const binsTruncated = (flags & 1) !== 0;
154
+ const fullBinCount = points >= 8 && points <= 14 ? (1 << (points - 1)) + 1 : 0;
155
+ if (!Number.isFinite(sampleRate) || sampleRate <= 0 || fullBinCount === 0 ||
156
+ (flags & ~1) !== 0 || payload.byteLength !== 12 + binCount * 8 ||
157
+ (points === 14
158
+ ? !binsTruncated || binCount !== 8190 || fullBinCount - binCount !== 3
159
+ : binsTruncated || binCount !== fullBinCount)) {
160
+ return null;
161
+ }
162
+ const currentDb = new Float32Array(binCount);
163
+ const peakDb = new Float32Array(binCount);
164
+ const peakOffset = 12 + binCount * 4;
165
+ for (let bin = 0; bin < binCount; bin++) {
166
+ const current = payload.getFloat32(12 + bin * 4, true);
167
+ const peak = payload.getFloat32(peakOffset + bin * 4, true);
168
+ if (!Number.isFinite(current) || !Number.isFinite(peak)) return null;
169
+ currentDb[bin] = current;
170
+ peakDb[bin] = peak;
171
+ }
172
+ return {
173
+ ...common(node, 'spectrum', sequence, dropped),
174
+ sampleRate,
175
+ points,
176
+ binsTruncated,
177
+ currentDb,
178
+ peakDb
179
+ };
180
+ }
181
+
182
+ function decodeSpectrogram(payload, node, sequence, dropped) {
183
+ if (payload.byteLength !== 268) return null;
184
+ const sampleRate = payload.getFloat32(0, true);
185
+ const timeSeconds = payload.getFloat32(4, true);
186
+ const cellCount = payload.getUint16(8, true);
187
+ const points = payload.getUint16(10, true);
188
+ if (!Number.isFinite(sampleRate) || sampleRate <= 0 || !Number.isFinite(timeSeconds) ||
189
+ cellCount !== 256 || points < 8 || points > 14) {
190
+ return null;
191
+ }
192
+ const intensities = new Uint8Array(256);
193
+ for (let cell = 0; cell < 256; cell++) intensities[cell] = payload.getUint8(12 + cell);
194
+ return {
195
+ ...common(node, 'spectrogram', sequence, dropped),
196
+ sampleRate,
197
+ timeSeconds,
198
+ points,
199
+ intensities
200
+ };
201
+ }
202
+
203
+ function decodeStereo(payload, node, sequence, dropped) {
204
+ if (payload.byteLength < 1464) return null;
205
+ const sampleRate = payload.getFloat32(0, true);
206
+ const sampleCount = payload.getUint16(4, true);
207
+ const flags = payload.getUint16(6, true);
208
+ const expectedBytes = 8 + sampleCount * 8 + 360 * 4 + 16;
209
+ if (!Number.isFinite(sampleRate) || sampleRate <= 0 || sampleCount > 8000 ||
210
+ (flags & ~1) !== 0 || payload.byteLength !== expectedBytes) {
211
+ return null;
212
+ }
213
+ const samples = new Float32Array(sampleCount * 2);
214
+ for (let sample = 0; sample < sampleCount; sample++) {
215
+ const offset = 8 + sample * 8;
216
+ const side = payload.getFloat32(offset, true);
217
+ const mid = payload.getFloat32(offset + 4, true);
218
+ if (!Number.isFinite(side) || !Number.isFinite(mid)) return null;
219
+ samples[sample * 2] = side;
220
+ samples[sample * 2 + 1] = mid;
221
+ }
222
+ const envelopeOffset = 8 + sampleCount * 8;
223
+ const envelope = new Float32Array(360);
224
+ for (let bin = 0; bin < 360; bin++) {
225
+ const peak = payload.getFloat32(envelopeOffset + bin * 4, true);
226
+ if (!Number.isFinite(peak) || peak < 0) return null;
227
+ envelope[bin] = peak;
228
+ }
229
+ const statisticsOffset = envelopeOffset + 360 * 4;
230
+ const correlation = payload.getFloat32(statisticsOffset, true);
231
+ const balance = payload.getFloat32(statisticsOffset + 4, true);
232
+ const peakLeft = payload.getFloat32(statisticsOffset + 8, true);
233
+ const peakRight = payload.getFloat32(statisticsOffset + 12, true);
234
+ if (!Number.isFinite(correlation) || correlation < -1 || correlation > 1 ||
235
+ !Number.isFinite(balance) || !Number.isFinite(peakLeft) || peakLeft < 0 ||
236
+ !Number.isFinite(peakRight) || peakRight < 0) {
237
+ return null;
238
+ }
239
+ return {
240
+ ...common(node, 'stereo', sequence, dropped),
241
+ sampleRate,
242
+ discontinuity: (flags & 1) !== 0,
243
+ samples,
244
+ envelope,
245
+ correlation,
246
+ balance,
247
+ peakLeft,
248
+ peakRight
249
+ };
250
+ }
251
+
252
+ function decodePayload(frameType, payload, node, sequence, dropped) {
253
+ switch (frameType) {
254
+ case LEVEL_FRAME:
255
+ return decodeLevel(payload, node, sequence, dropped);
256
+ case SCOPE_FRAME:
257
+ return decodeOscilloscope(payload, node, sequence, dropped);
258
+ case SPECTRUM_FRAME:
259
+ return decodeSpectrum(payload, node, sequence, dropped);
260
+ case SPECTROGRAM_FRAME:
261
+ return decodeSpectrogram(payload, node, sequence, dropped);
262
+ case STEREO_FRAME:
263
+ return decodeStereo(payload, node, sequence, dropped);
264
+ default:
265
+ return null;
266
+ }
267
+ }
268
+
269
+ export function decodeTelemetryPacket(packet, bytes, nodesByTap, initialDropped = 0) {
270
+ if (!(packet instanceof Uint8Array) || !Number.isInteger(bytes) ||
271
+ bytes < 0 || bytes > packet.byteLength) {
272
+ return { frames: [], pendingDropped: initialDropped };
273
+ }
274
+ const view = new DataView(packet.buffer, packet.byteOffset, bytes);
275
+ const frames = [];
276
+ let offset = 0;
277
+ let pendingDropped = initialDropped;
278
+ while (offset < bytes) {
279
+ if (bytes - offset < HEADER_BYTES) break;
280
+ const frameType = view.getUint16(offset, true);
281
+ const formatVersion = view.getUint16(offset + 2, true);
282
+ const tapId = view.getUint32(offset + 4, true);
283
+ const sequence = view.getUint32(offset + 8, true);
284
+ const payloadBytes = view.getUint16(offset + 12, true);
285
+ const frameBytes = (HEADER_BYTES + payloadBytes + 3) & ~3;
286
+ if (frameBytes > bytes - offset) break;
287
+ const node = nodesByTap.get(tapId);
288
+ const expected = node ? ANALYZER_FRAMES[node.effectType] : null;
289
+ if (expected?.[0] === frameType && expected[1] === formatVersion) {
290
+ const payload = new DataView(
291
+ packet.buffer,
292
+ packet.byteOffset + offset + HEADER_BYTES,
293
+ payloadBytes
294
+ );
295
+ const decoded = decodePayload(frameType, payload, node, sequence, pendingDropped);
296
+ if (decoded) {
297
+ frames.push(decoded);
298
+ pendingDropped = 0;
299
+ }
300
+ }
301
+ offset += frameBytes;
302
+ }
303
+ return { frames, pendingDropped };
304
+ }
305
+
306
+ export function countTelemetryFrames(packet, bytes) {
307
+ if (!(packet instanceof Uint8Array) || !Number.isInteger(bytes) ||
308
+ bytes < 0 || bytes > packet.byteLength) {
309
+ return 0;
310
+ }
311
+ const view = new DataView(packet.buffer, packet.byteOffset, bytes);
312
+ let count = 0;
313
+ let offset = 0;
314
+ while (bytes - offset >= HEADER_BYTES) {
315
+ const payloadBytes = view.getUint16(offset + 12, true);
316
+ const frameBytes = (HEADER_BYTES + payloadBytes + 3) & ~3;
317
+ if (frameBytes > bytes - offset) break;
318
+ count += 1;
319
+ offset += frameBytes;
320
+ }
321
+ return count;
322
+ }