@juandinella/audio-bands 0.4.7 → 0.5.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/README.md CHANGED
@@ -4,18 +4,21 @@
4
4
 
5
5
  **Demo**: [audio-bands.juandinella.com](https://audio-bands.juandinella.com)
6
6
 
7
- Headless audio analysis for the browser. Get normalized `bass`, `mid`, `high`, custom named bands, raw FFT bins, or time-domain waveform data without shipping a renderer.
7
+ Headless audio analysis for the browser. Read a consistent frame of low/mid/high energy regions, custom named bands, raw FFT bins, and waveform data without shipping a renderer.
8
8
 
9
9
  ```ts
10
- const { bass, mid, high } = audio.getBands();
11
- const custom = audio.getCustomBands();
12
- const fft = audio.getFftData();
10
+ const frame = audio.snapshot();
11
+ const { bass, mid, high } = frame.bands;
12
+ const custom = frame.customBands;
13
+ const fft = frame.fft;
13
14
  ```
14
15
 
15
16
  ## Why
16
17
 
17
18
  Most audio libraries either only play audio or immediately draw a canvas for you. This one stays lower level: it gives you usable analysis data and lets you decide how to render it.
18
19
 
20
+ The intended center of the API is `snapshot()`: one call, one coherent analysis frame.
21
+
19
22
  ## Install
20
23
 
21
24
  ```bash
@@ -51,12 +54,14 @@ const audio = new AudioBands({
51
54
  });
52
55
 
53
56
  await audio.load('/track.mp3');
57
+ await audio.play();
54
58
 
55
59
  function loop() {
56
- const { bass, mid, high, overall } = audio.getBands();
57
- const custom = audio.getCustomBands();
58
- const fft = audio.getFftData();
59
- const waveform = audio.getWaveform();
60
+ const frame = audio.snapshot();
61
+ const { bass, mid, high, overall } = frame.bands;
62
+ const custom = frame.customBands;
63
+ const fft = frame.fft;
64
+ const waveform = frame.waveform;
60
65
 
61
66
  requestAnimationFrame(loop);
62
67
  }
@@ -74,8 +79,16 @@ function Visualizer() {
74
79
  isPlaying,
75
80
  hasTrack,
76
81
  loadError,
82
+ playbackError,
77
83
  micError,
78
84
  loadTrack,
85
+ play,
86
+ pause,
87
+ setLoop,
88
+ seek,
89
+ getDuration,
90
+ getCurrentTime,
91
+ snapshot,
79
92
  togglePlayPause,
80
93
  toggleMic,
81
94
  getBands,
@@ -86,12 +99,27 @@ function Visualizer() {
86
99
  },
87
100
  });
88
101
 
102
+ const frame = snapshot();
103
+
89
104
  return (
90
105
  <>
91
106
  <button onClick={() => loadTrack('/track.mp3')}>load</button>
92
- <button onClick={togglePlayPause}>{isPlaying ? 'Pause' : 'Play'}</button>
107
+ <button onClick={play}>play</button>
108
+ <button onClick={pause}>pause</button>
109
+ <button onClick={() => setLoop(true)}>loop</button>
110
+ <button onClick={() => seek(30)}>seek 0:30</button>
111
+ <button onClick={togglePlayPause}>toggle</button>
93
112
  <button onClick={toggleMic}>Toggle mic</button>
94
- <pre>{JSON.stringify({ hasTrack, loadError, micError, ...getBands(), ...getCustomBands() }, null, 2)}</pre>
113
+ <pre>{JSON.stringify({
114
+ hasTrack,
115
+ loadError,
116
+ playbackError,
117
+ micError,
118
+ duration: getDuration(),
119
+ currentTime: getCurrentTime(),
120
+ ...frame.bands,
121
+ ...frame.customBands,
122
+ }, null, 2)}</pre>
95
123
  </>
96
124
  );
97
125
  }
@@ -109,7 +137,24 @@ const waveform = audio.getWaveform('mic');
109
137
 
110
138
  ## When To Use Bands Vs FFT
111
139
 
112
- Use `getBands()` when you want stable, simple control signals:
140
+ ## What `bass`, `mid`, `high`, and `overall` Mean
141
+
142
+ `getBands()` returns three coarse analyser regions plus a convenience summary value:
143
+
144
+ - `bass`, `mid`, and `high` are normalized slices of the analyser spectrum, not fixed acoustic bands in Hz
145
+ - the default split is percentage-based (`0-0.08`, `0.08-0.4`, `0.4-1`) over the available FFT bins
146
+ - those regions therefore depend on analyser resolution and the underlying audio context sample rate
147
+ - `overall` is a UI-oriented weighted summary (`bass * 0.5 + mid * 0.3 + high * 0.2`), not a perceptual loudness metric
148
+
149
+ Use these values as stable control signals for interaction and motion. If you need tighter semantic control, define `customBands`. If you need physically meaningful bin-level data, use `getFftData()` or `snapshot()`.
150
+
151
+ Use `snapshot()` first when you need a full analysis frame:
152
+
153
+ - read `bands`, `customBands`, `fft`, and `waveform` together
154
+ - avoid multiple analyser reads in one render loop
155
+ - keep derived values synchronized
156
+
157
+ Use `getBands()` when you only want stable, simple control signals:
113
158
 
114
159
  - pulsing a blob with low-end energy
115
160
  - scaling UI based on overall intensity
@@ -147,11 +192,18 @@ new AudioBands(options?: AudioBandsOptions)
147
192
 
148
193
  | Method | Description |
149
194
  | ----------------------- | ----------- |
150
- | `load(url)` | Load and play a track. Rejects with `AudioBandsError` on failure. |
151
- | `togglePlayPause()` | Toggle the current track. |
195
+ | `load(url)` | Load a track and connect it to the analyser. Rejects with `AudioBandsError` on failure. |
196
+ | `play()` | Start playback for the current track. Rejects with `AudioBandsError` on failure. |
197
+ | `pause()` | Pause the current track. |
198
+ | `setLoop(loop)` | Set whether the current and future loaded tracks should loop. |
199
+ | `seek(seconds)` | Seek the current track to a given time in seconds. |
200
+ | `getDuration()` | Returns the current track duration in seconds, or `null` when unavailable. |
201
+ | `getCurrentTime()` | Returns the current playback time in seconds, or `null` when unavailable. |
202
+ | `togglePlayPause()` | Toggle the current track. Returns a promise and propagates playback errors when toggling into play. |
152
203
  | `enableMic()` | Request microphone access and start mic analysis. Rejects with `AudioBandsError` on failure. |
153
204
  | `disableMic()` | Stop mic input and clean up the stream. |
154
- | `getBands(source?)` | Returns normalized `{ bass, mid, high, overall }`. |
205
+ | `snapshot(source?)` | Returns `{ bands, customBands, fft, waveform }` from a single analyser read. |
206
+ | `getBands(source?)` | Returns normalized analyser-region energy `{ bass, mid, high, overall }`. |
155
207
  | `getCustomBands(source?)` | Returns normalized values for configured custom bands. |
156
208
  | `getFftData(source?)` | Returns raw `Uint8Array` frequency bins. |
157
209
  | `getWaveform(source?)` | Returns raw time-domain data for `'music'` or `'mic'`. |
@@ -167,11 +219,19 @@ const {
167
219
  hasTrack,
168
220
  audioError,
169
221
  loadError,
222
+ playbackError,
170
223
  micError,
171
224
  state,
172
225
  loadTrack,
226
+ play,
227
+ pause,
228
+ setLoop,
229
+ seek,
230
+ getDuration,
231
+ getCurrentTime,
173
232
  togglePlayPause,
174
233
  toggleMic,
234
+ snapshot,
175
235
  getBands,
176
236
  getCustomBands,
177
237
  getFftData,
@@ -199,6 +259,7 @@ type AudioBandsOptions = {
199
259
  customBands?: Record<string, { from: number; to: number }>;
200
260
  onError?: (error: AudioBandsError) => void;
201
261
  onLoadError?: (error: AudioBandsError) => void;
262
+ onPlaybackError?: (error: AudioBandsError) => void;
202
263
  onMicError?: (error: AudioBandsError) => void;
203
264
  onStateChange?: (state: AudioBandsState) => void;
204
265
  onPlay?: () => void;
@@ -216,6 +277,7 @@ type AudioBandsState = {
216
277
  micActive: boolean;
217
278
  hasTrack: boolean; // a track source is assigned, even if playback later fails
218
279
  loadError: AudioBandsError | null;
280
+ playbackError: AudioBandsError | null;
219
281
  micError: AudioBandsError | null;
220
282
  };
221
283
  ```
@@ -223,12 +285,20 @@ type AudioBandsState = {
223
285
  ## Notes
224
286
 
225
287
  - `AudioContext` is created lazily on the first call to `load()` or `enableMic()`.
288
+ - `load()` prepares the current track but does not start playback. Call `play()` or `togglePlayPause()` after loading.
289
+ - `togglePlayPause()` follows the same playback error contract as `play()`: if toggling into play fails, the returned promise rejects.
226
290
  - `hasTrack` means a track source is currently assigned to the instance. It can still be `true` if `play()` fails due to autoplay policy or another playback error.
291
+ - `loadError` stores track loading failures only.
292
+ - `playbackError` stores playback failures for the current track, such as autoplay-policy rejections.
293
+ - In the React hook, changing `music`, `mic`, `bandRanges`, or `customBands` recreates the underlying `AudioBands` instance.
227
294
  - The mic analyser is not connected to `AudioContext.destination`, so it will not feed back into the speakers.
228
- - `getBands()`, `getCustomBands()`, `getFftData()`, and `getWaveform()` read live data. Call them inside `requestAnimationFrame`, not from React state updates.
295
+ - `snapshot()` is the preferred way to read analysis inside `requestAnimationFrame`.
296
+ - `getBands()`, `getCustomBands()`, `getFftData()`, and `getWaveform()` are convenience reads when you only need one view of the current frame.
229
297
  - `getFftData()` returns the same underlying buffer on each call. Copy it if you need frame-to-frame comparisons.
230
298
  - `fftSize` must be a power of two between `32` and `32768`.
231
299
  - Band ranges are normalized from `0` to `1`, where `0` is the start of the analyser spectrum and `1` is the end.
300
+ - The default `bass` / `mid` / `high` labels are convenience names for analyser regions, not fixed Hz buckets.
301
+ - `overall` is intended as a simple UI summary, not as an acoustically weighted loudness value.
232
302
 
233
303
  ## License
234
304
 
@@ -114,6 +114,7 @@ var AudioBands = class {
114
114
  micActive: false,
115
115
  hasTrack: false,
116
116
  loadError: null,
117
+ playbackError: null,
117
118
  micError: null
118
119
  };
119
120
  this.ctx = null;
@@ -127,6 +128,7 @@ var AudioBands = class {
127
128
  this.musicSource = null;
128
129
  this.micSource = null;
129
130
  this.micStream = null;
131
+ this.trackLoop = false;
130
132
  this.destroyed = false;
131
133
  this.options = options;
132
134
  this.musicConfig = normalizeAnalyserConfig(options.music, DEFAULT_MUSIC_ANALYSER);
@@ -147,41 +149,69 @@ var AudioBands = class {
147
149
  try {
148
150
  ctx = this.ensureCtx();
149
151
  } catch (error) {
150
- throw this.handleError("load", error);
152
+ throw this.handleError("load", error, "load_error");
151
153
  }
152
154
  this.teardownMusic();
153
155
  const audio = new Audio();
154
156
  audio.crossOrigin = "anonymous";
155
157
  audio.src = url;
156
- audio.loop = true;
158
+ audio.loop = this.trackLoop;
157
159
  this.audioEl = audio;
158
- this.setState({ hasTrack: true, loadError: null });
160
+ this.setState({ hasTrack: true, loadError: null, playbackError: null });
159
161
  const source = ctx.createMediaElementSource(audio);
160
162
  source.connect(this.musicAnalyser);
161
163
  this.musicSource = source;
164
+ }
165
+ async play() {
166
+ const audio = this.audioEl;
167
+ if (!audio) return;
162
168
  try {
163
169
  await audio.play();
164
- this.setState({ isPlaying: true, loadError: null });
170
+ this.setState({ isPlaying: true, playbackError: null });
165
171
  this.options.onPlay?.();
166
172
  } catch (error) {
167
- throw this.handleError("load", error, "load_error");
173
+ throw this.handleError("playback", error, "playback_error");
168
174
  }
169
175
  }
170
- togglePlayPause() {
176
+ pause() {
177
+ const audio = this.audioEl;
178
+ if (!audio || audio.paused) return;
179
+ audio.pause();
180
+ this.setState({ isPlaying: false });
181
+ this.options.onPause?.();
182
+ }
183
+ async togglePlayPause() {
171
184
  const audio = this.audioEl;
172
185
  if (!audio) return;
173
186
  if (audio.paused) {
174
- void audio.play().then(() => {
175
- this.setState({ isPlaying: true, loadError: null });
176
- this.options.onPlay?.();
177
- }).catch((error) => {
178
- this.handleError("load", error, "playback_error");
179
- });
187
+ await this.play();
180
188
  return;
181
189
  }
182
- audio.pause();
183
- this.setState({ isPlaying: false });
184
- this.options.onPause?.();
190
+ this.pause();
191
+ }
192
+ setLoop(loop) {
193
+ this.trackLoop = loop;
194
+ if (this.audioEl) this.audioEl.loop = loop;
195
+ }
196
+ seek(seconds) {
197
+ if (!Number.isFinite(seconds) || seconds < 0) {
198
+ throw new AudioBandsError(
199
+ "config",
200
+ "invalid_config",
201
+ "seek time must be a finite number greater than or equal to 0"
202
+ );
203
+ }
204
+ if (!this.audioEl) return;
205
+ const duration = Number.isFinite(this.audioEl.duration) ? this.audioEl.duration : null;
206
+ this.audioEl.currentTime = duration === null ? seconds : Math.min(seconds, duration);
207
+ }
208
+ getDuration() {
209
+ if (!this.audioEl) return null;
210
+ return Number.isFinite(this.audioEl.duration) ? this.audioEl.duration : null;
211
+ }
212
+ getCurrentTime() {
213
+ if (!this.audioEl) return null;
214
+ return Number.isFinite(this.audioEl.currentTime) ? this.audioEl.currentTime : null;
185
215
  }
186
216
  async enableMic() {
187
217
  let ctx;
@@ -240,6 +270,16 @@ var AudioBands = class {
240
270
  getWaveform(source = "music") {
241
271
  return this.readWaveformData(source);
242
272
  }
273
+ snapshot(source = "music") {
274
+ const fft = this.readFrequencyData(source);
275
+ const waveform = this.readWaveformData(source);
276
+ return {
277
+ bands: fft ? computeBands(fft, this.classicRanges) : { ...ZERO },
278
+ customBands: fft ? computeCustomBands(fft, this.customBandRanges) : computeCustomBands(new Uint8Array(1), this.customBandRanges),
279
+ fft,
280
+ waveform
281
+ };
282
+ }
243
283
  destroy() {
244
284
  if (this.destroyed) return;
245
285
  this.teardownMusic();
@@ -307,16 +347,19 @@ var AudioBands = class {
307
347
  analyser.smoothingTimeConstant = config.smoothingTimeConstant;
308
348
  return analyser;
309
349
  }
310
- handleError(kind, error, fallbackCode = kind === "mic" ? "mic_error" : "load_error") {
350
+ handleError(kind, error, fallbackCode = kind === "mic" ? "mic_error" : kind === "playback" ? "playback_error" : "load_error") {
311
351
  const wrapped = error instanceof AudioBandsError ? error : new AudioBandsError(
312
352
  kind,
313
353
  fallbackCode,
314
- kind === "mic" ? "Failed to access microphone input" : "Failed to load or play audio track",
354
+ kind === "mic" ? "Failed to access microphone input" : kind === "playback" ? "Failed to play audio track" : "Failed to load audio track",
315
355
  error
316
356
  );
317
357
  if (kind === "load") {
318
358
  this.setState({ isPlaying: false, loadError: wrapped });
319
359
  this.options.onLoadError?.(wrapped);
360
+ } else if (kind === "playback") {
361
+ this.setState({ isPlaying: false, playbackError: wrapped });
362
+ this.options.onPlaybackError?.(wrapped);
320
363
  } else {
321
364
  this.setState({ micActive: false, micError: wrapped });
322
365
  this.options.onMicError?.(wrapped);
@@ -347,7 +390,11 @@ var AudioBands = class {
347
390
  }
348
391
  this.musicSource = null;
349
392
  this.musicWaveformData = this.musicAnalyser ? new Uint8Array(this.musicAnalyser.fftSize) : null;
350
- this.setState({ isPlaying: false, hasTrack: false });
393
+ this.setState({
394
+ isPlaying: false,
395
+ hasTrack: false,
396
+ playbackError: null
397
+ });
351
398
  }
352
399
  };
353
400
 
@@ -355,4 +402,4 @@ export {
355
402
  AudioBandsError,
356
403
  AudioBands
357
404
  };
358
- //# sourceMappingURL=chunk-33JHLQZJ.js.map
405
+ //# sourceMappingURL=chunk-BA6JOXL4.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/errors.ts","../src/core.ts"],"sourcesContent":["import type { AudioBandsErrorCode, AudioBandsErrorKind } from './types';\n\nexport class AudioBandsError extends Error {\n readonly kind: AudioBandsErrorKind;\n readonly code: AudioBandsErrorCode;\n readonly cause?: unknown;\n\n constructor(\n kind: AudioBandsErrorKind,\n code: AudioBandsErrorCode,\n message: string,\n cause?: unknown,\n ) {\n super(message);\n this.name = 'AudioBandsError';\n this.kind = kind;\n this.code = code;\n this.cause = cause;\n }\n}\n","import { AudioBandsError } from './errors';\nimport type {\n AudioAnalyserConfig,\n AudioBandsOptions,\n AudioBandsSnapshot,\n AudioBandsState,\n AudioSource,\n BandRange,\n Bands,\n ClassicBandRanges,\n CustomBandRanges,\n} from './types';\n\nconst DEFAULT_MUSIC_ANALYSER: Required<AudioAnalyserConfig> = {\n fftSize: 256,\n smoothingTimeConstant: 0.85,\n};\n\nconst DEFAULT_MIC_ANALYSER: Required<AudioAnalyserConfig> = {\n fftSize: 256,\n smoothingTimeConstant: 0.8,\n};\n\nconst DEFAULT_CLASSIC_RANGES: Record<keyof Omit<Bands, 'overall'>, BandRange> = {\n bass: { from: 0, to: 0.08 },\n mid: { from: 0.08, to: 0.4 },\n high: { from: 0.4, to: 1 },\n};\n\nconst ZERO: Bands = { bass: 0, mid: 0, high: 0, overall: 0 };\n\nfunction avg(arr: Uint8Array<ArrayBuffer>, from: number, to: number): number {\n let sum = 0;\n for (let i = from; i < to; i++) sum += arr[i];\n return sum / (to - from);\n}\n\nfunction isPowerOfTwo(value: number): boolean {\n return (value & (value - 1)) === 0;\n}\n\nfunction normalizeAnalyserConfig(\n config: AudioAnalyserConfig | undefined,\n fallback: Required<AudioAnalyserConfig>,\n): Required<AudioAnalyserConfig> {\n const fftSize = config?.fftSize ?? fallback.fftSize;\n const smoothingTimeConstant =\n config?.smoothingTimeConstant ?? fallback.smoothingTimeConstant;\n\n if (\n !Number.isInteger(fftSize) ||\n fftSize < 32 ||\n fftSize > 32768 ||\n !isPowerOfTwo(fftSize)\n ) {\n throw new AudioBandsError(\n 'config',\n 'invalid_config',\n 'fftSize must be a power of two between 32 and 32768',\n );\n }\n\n if (\n typeof smoothingTimeConstant !== 'number' ||\n smoothingTimeConstant < 0 ||\n smoothingTimeConstant > 1\n ) {\n throw new AudioBandsError(\n 'config',\n 'invalid_config',\n 'smoothingTimeConstant must be between 0 and 1',\n );\n }\n\n return { fftSize, smoothingTimeConstant };\n}\n\nfunction normalizeRange(name: string, range: BandRange | undefined): BandRange {\n const normalized = range ?? DEFAULT_CLASSIC_RANGES[name as keyof typeof DEFAULT_CLASSIC_RANGES];\n\n if (\n typeof normalized?.from !== 'number' ||\n typeof normalized?.to !== 'number' ||\n normalized.from < 0 ||\n normalized.to > 1 ||\n normalized.from >= normalized.to\n ) {\n throw new AudioBandsError(\n 'config',\n 'invalid_config',\n `Band range \"${name}\" must satisfy 0 <= from < to <= 1`,\n );\n }\n\n return normalized;\n}\n\nfunction normalizeClassicRanges(\n ranges: ClassicBandRanges | undefined,\n): Record<keyof Omit<Bands, 'overall'>, BandRange> {\n return {\n bass: normalizeRange('bass', ranges?.bass),\n mid: normalizeRange('mid', ranges?.mid),\n high: normalizeRange('high', ranges?.high),\n };\n}\n\nfunction normalizeCustomBands(customBands: CustomBandRanges | undefined): CustomBandRanges {\n if (!customBands) return {};\n\n return Object.fromEntries(\n Object.entries(customBands).map(([name, range]) => [name, normalizeRange(name, range)]),\n );\n}\n\nfunction getIndexes(len: number, range: BandRange): [number, number] {\n const from = Math.max(0, Math.min(len - 1, Math.floor(len * range.from)));\n const to = Math.max(from + 1, Math.min(len, Math.floor(len * range.to)));\n return [from, to];\n}\n\nfunction getRangeValue(data: Uint8Array<ArrayBuffer>, range: BandRange): number {\n const [from, to] = getIndexes(data.length, range);\n return avg(data, from, to) / 255;\n}\n\nfunction fillFrequencyData(\n analyser: AnalyserNode,\n data: Uint8Array<ArrayBuffer>,\n): Uint8Array<ArrayBuffer> {\n analyser.getByteFrequencyData(data);\n return data;\n}\n\nfunction computeBands(\n data: Uint8Array<ArrayBuffer>,\n ranges: Record<keyof Omit<Bands, 'overall'>, BandRange>,\n): Bands {\n const bass = getRangeValue(data, ranges.bass);\n const mid = getRangeValue(data, ranges.mid);\n const high = getRangeValue(data, ranges.high);\n\n return {\n bass,\n mid,\n high,\n overall: bass * 0.5 + mid * 0.3 + high * 0.2,\n };\n}\n\nfunction computeCustomBands(\n data: Uint8Array<ArrayBuffer>,\n ranges: CustomBandRanges,\n): Record<string, number> {\n return Object.fromEntries(\n Object.entries(ranges).map(([name, range]) => [name, getRangeValue(data, range)]),\n );\n}\n\nfunction cloneState(state: AudioBandsState): AudioBandsState {\n return { ...state };\n}\n\n/**\n * Vanilla JS class — no framework dependency.\n * Works in React, Vue, Svelte, or plain HTML.\n */\nexport class AudioBands {\n private options: AudioBandsOptions;\n private readonly musicConfig: Required<AudioAnalyserConfig>;\n private readonly micConfig: Required<AudioAnalyserConfig>;\n private readonly classicRanges: Record<keyof Omit<Bands, 'overall'>, BandRange>;\n private readonly customBandRanges: CustomBandRanges;\n\n private readonly state: AudioBandsState = {\n isPlaying: false,\n micActive: false,\n hasTrack: false,\n loadError: null,\n playbackError: null,\n micError: null,\n };\n\n private ctx: AudioContext | null = null;\n private musicAnalyser: AnalyserNode | null = null;\n private musicData: Uint8Array<ArrayBuffer> | null = null;\n private musicWaveformData: Uint8Array<ArrayBuffer> | null = null;\n private micAnalyser: AnalyserNode | null = null;\n private micData: Uint8Array<ArrayBuffer> | null = null;\n private micWaveformData: Uint8Array<ArrayBuffer> | null = null;\n private audioEl: HTMLAudioElement | null = null;\n private musicSource: MediaElementAudioSourceNode | null = null;\n private micSource: MediaStreamAudioSourceNode | null = null;\n private micStream: MediaStream | null = null;\n private trackLoop = false;\n private destroyed = false;\n\n constructor(options: AudioBandsOptions = {}) {\n this.options = options;\n this.musicConfig = normalizeAnalyserConfig(options.music, DEFAULT_MUSIC_ANALYSER);\n this.micConfig = normalizeAnalyserConfig(options.mic, DEFAULT_MIC_ANALYSER);\n this.classicRanges = normalizeClassicRanges(options.bandRanges);\n this.customBandRanges = normalizeCustomBands(options.customBands);\n }\n\n getState(): AudioBandsState {\n return cloneState(this.state);\n }\n\n getCustomBands(source: AudioSource = 'music'): Record<string, number> {\n const data = this.readFrequencyData(source);\n if (!data) return computeCustomBands(new Uint8Array(1) as Uint8Array<ArrayBuffer>, this.customBandRanges);\n return computeCustomBands(data, this.customBandRanges);\n }\n\n async load(url: string): Promise<void> {\n let ctx: AudioContext;\n try {\n ctx = this.ensureCtx();\n } catch (error) {\n throw this.handleError('load', error, 'load_error');\n }\n\n this.teardownMusic();\n\n const audio = new Audio();\n audio.crossOrigin = 'anonymous';\n audio.src = url;\n audio.loop = this.trackLoop;\n this.audioEl = audio;\n this.setState({ hasTrack: true, loadError: null, playbackError: null });\n\n const source = ctx.createMediaElementSource(audio);\n source.connect(this.musicAnalyser!);\n this.musicSource = source;\n }\n\n async play(): Promise<void> {\n const audio = this.audioEl;\n if (!audio) return;\n\n try {\n await audio.play();\n this.setState({ isPlaying: true, playbackError: null });\n this.options.onPlay?.();\n } catch (error) {\n throw this.handleError('playback', error, 'playback_error');\n }\n }\n\n pause(): void {\n const audio = this.audioEl;\n if (!audio || audio.paused) return;\n\n audio.pause();\n this.setState({ isPlaying: false });\n this.options.onPause?.();\n }\n\n async togglePlayPause(): Promise<void> {\n const audio = this.audioEl;\n if (!audio) return;\n\n if (audio.paused) {\n await this.play();\n return;\n }\n\n this.pause();\n }\n\n setLoop(loop: boolean): void {\n this.trackLoop = loop;\n if (this.audioEl) this.audioEl.loop = loop;\n }\n\n seek(seconds: number): void {\n if (!Number.isFinite(seconds) || seconds < 0) {\n throw new AudioBandsError(\n 'config',\n 'invalid_config',\n 'seek time must be a finite number greater than or equal to 0',\n );\n }\n\n if (!this.audioEl) return;\n\n const duration = Number.isFinite(this.audioEl.duration) ? this.audioEl.duration : null;\n this.audioEl.currentTime = duration === null ? seconds : Math.min(seconds, duration);\n }\n\n getDuration(): number | null {\n if (!this.audioEl) return null;\n return Number.isFinite(this.audioEl.duration) ? this.audioEl.duration : null;\n }\n\n getCurrentTime(): number | null {\n if (!this.audioEl) return null;\n return Number.isFinite(this.audioEl.currentTime) ? this.audioEl.currentTime : null;\n }\n\n async enableMic(): Promise<void> {\n let ctx: AudioContext;\n try {\n ctx = this.ensureCtx();\n } catch (error) {\n throw this.handleError('mic', error);\n }\n\n if (this.micStream) return;\n\n try {\n const stream = await navigator.mediaDevices.getUserMedia({\n audio: true,\n video: false,\n });\n this.micStream = stream;\n\n const analyser = this.createAnalyser(ctx, this.micConfig);\n this.micAnalyser = analyser;\n this.micData = new Uint8Array(\n analyser.frequencyBinCount,\n ) as Uint8Array<ArrayBuffer>;\n this.micWaveformData = new Uint8Array(\n analyser.fftSize,\n ) as Uint8Array<ArrayBuffer>;\n\n const source = ctx.createMediaStreamSource(stream);\n source.connect(analyser);\n this.micSource = source;\n\n this.setState({ micActive: true, micError: null });\n this.options.onMicStart?.();\n } catch (error) {\n throw this.handleError('mic', error, 'mic_error');\n }\n }\n\n disableMic(): void {\n const hadMic = Boolean(this.micStream || this.micSource || this.micAnalyser);\n this.micStream?.getTracks().forEach((track) => track.stop());\n this.micStream = null;\n\n try {\n this.micSource?.disconnect();\n } catch {\n /* already disconnected */\n }\n\n this.micSource = null;\n this.micAnalyser = null;\n this.micData = null;\n this.micWaveformData = null;\n this.setState({ micActive: false });\n\n if (hadMic) this.options.onMicStop?.();\n }\n\n getBands(source: AudioSource = 'music'): Bands {\n const data = this.readFrequencyData(source);\n if (!data) return { ...ZERO };\n return computeBands(data, this.classicRanges);\n }\n\n getFftData(source: AudioSource = 'music'): Uint8Array<ArrayBuffer> | null {\n return this.readFrequencyData(source);\n }\n\n getWaveform(source: AudioSource = 'music'): Uint8Array<ArrayBuffer> | null {\n return this.readWaveformData(source);\n }\n\n snapshot(source: AudioSource = 'music'): AudioBandsSnapshot {\n const fft = this.readFrequencyData(source);\n const waveform = this.readWaveformData(source);\n\n return {\n bands: fft ? computeBands(fft, this.classicRanges) : { ...ZERO },\n customBands: fft\n ? computeCustomBands(fft, this.customBandRanges)\n : computeCustomBands(new Uint8Array(1) as Uint8Array<ArrayBuffer>, this.customBandRanges),\n fft,\n waveform,\n };\n }\n\n destroy(): void {\n if (this.destroyed) return;\n\n this.teardownMusic();\n this.disableMic();\n void this.ctx?.close();\n this.ctx = null;\n this.musicAnalyser = null;\n this.musicData = null;\n this.musicWaveformData = null;\n this.setState({ isPlaying: false, micActive: false, hasTrack: false });\n this.options = {};\n this.destroyed = true;\n }\n\n private readFrequencyData(source: AudioSource): Uint8Array<ArrayBuffer> | null {\n if (source === 'mic') {\n if (!this.micAnalyser || !this.micData) return null;\n return fillFrequencyData(this.micAnalyser, this.micData);\n }\n\n if (!this.musicAnalyser || !this.musicData) return null;\n return fillFrequencyData(this.musicAnalyser, this.musicData);\n }\n\n private readWaveformData(source: AudioSource): Uint8Array<ArrayBuffer> | null {\n if (source === 'mic') {\n if (!this.micAnalyser || !this.micWaveformData) return null;\n this.micAnalyser.getByteTimeDomainData(this.micWaveformData);\n return this.micWaveformData;\n }\n\n if (!this.musicAnalyser || !this.musicWaveformData) return null;\n this.musicAnalyser.getByteTimeDomainData(this.musicWaveformData);\n return this.musicWaveformData;\n }\n\n private ensureCtx(): AudioContext {\n if (this.destroyed) {\n throw new AudioBandsError(\n 'lifecycle',\n 'destroyed',\n 'This AudioBands instance was destroyed',\n );\n }\n\n if (this.ctx) return this.ctx;\n\n const Ctx =\n window.AudioContext ||\n (window as unknown as { webkitAudioContext?: typeof AudioContext })\n .webkitAudioContext;\n\n if (!Ctx) {\n throw new AudioBandsError(\n 'lifecycle',\n 'unsupported_audio_context',\n 'AudioContext is not supported in this environment',\n );\n }\n\n const ctx = new Ctx();\n const analyser = this.createAnalyser(ctx, this.musicConfig);\n analyser.connect(ctx.destination);\n\n this.ctx = ctx;\n this.musicAnalyser = analyser;\n this.musicData = new Uint8Array(\n analyser.frequencyBinCount,\n ) as Uint8Array<ArrayBuffer>;\n this.musicWaveformData = new Uint8Array(\n analyser.fftSize,\n ) as Uint8Array<ArrayBuffer>;\n\n return ctx;\n }\n\n private createAnalyser(\n ctx: AudioContext,\n config: Required<AudioAnalyserConfig>,\n ): AnalyserNode {\n const analyser = ctx.createAnalyser();\n analyser.fftSize = config.fftSize;\n analyser.smoothingTimeConstant = config.smoothingTimeConstant;\n return analyser;\n }\n\n private handleError(\n kind: 'load' | 'playback' | 'mic',\n error: unknown,\n fallbackCode: 'load_error' | 'playback_error' | 'mic_error' = kind === 'mic'\n ? 'mic_error'\n : kind === 'playback'\n ? 'playback_error'\n : 'load_error',\n ): AudioBandsError {\n const wrapped =\n error instanceof AudioBandsError\n ? error\n : new AudioBandsError(\n kind,\n fallbackCode,\n kind === 'mic'\n ? 'Failed to access microphone input'\n : kind === 'playback'\n ? 'Failed to play audio track'\n : 'Failed to load audio track',\n error,\n );\n\n if (kind === 'load') {\n this.setState({ isPlaying: false, loadError: wrapped });\n this.options.onLoadError?.(wrapped);\n } else if (kind === 'playback') {\n this.setState({ isPlaying: false, playbackError: wrapped });\n this.options.onPlaybackError?.(wrapped);\n } else {\n this.setState({ micActive: false, micError: wrapped });\n this.options.onMicError?.(wrapped);\n }\n\n this.options.onError?.(wrapped);\n return wrapped;\n }\n\n private setState(patch: Partial<AudioBandsState>): void {\n let changed = false;\n\n for (const [key, value] of Object.entries(patch) as Array<\n [keyof AudioBandsState, AudioBandsState[keyof AudioBandsState]]\n >) {\n if (this.state[key] !== value) {\n this.state[key] = value as never;\n changed = true;\n }\n }\n\n if (changed) this.options.onStateChange?.(this.getState());\n }\n\n private teardownMusic(): void {\n this.audioEl?.pause();\n if (this.audioEl) {\n this.audioEl.src = '';\n this.audioEl.load();\n }\n this.audioEl = null;\n\n try {\n this.musicSource?.disconnect();\n } catch {\n /* already disconnected */\n }\n\n this.musicSource = null;\n this.musicWaveformData = this.musicAnalyser\n ? new Uint8Array(this.musicAnalyser.fftSize) as Uint8Array<ArrayBuffer>\n : null;\n this.setState({\n isPlaying: false,\n hasTrack: false,\n playbackError: null,\n });\n }\n}\n"],"mappings":"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","names":[]}
@@ -141,6 +141,7 @@ var AudioBands = class {
141
141
  micActive: false,
142
142
  hasTrack: false,
143
143
  loadError: null,
144
+ playbackError: null,
144
145
  micError: null
145
146
  };
146
147
  this.ctx = null;
@@ -154,6 +155,7 @@ var AudioBands = class {
154
155
  this.musicSource = null;
155
156
  this.micSource = null;
156
157
  this.micStream = null;
158
+ this.trackLoop = false;
157
159
  this.destroyed = false;
158
160
  this.options = options;
159
161
  this.musicConfig = normalizeAnalyserConfig(options.music, DEFAULT_MUSIC_ANALYSER);
@@ -174,41 +176,69 @@ var AudioBands = class {
174
176
  try {
175
177
  ctx = this.ensureCtx();
176
178
  } catch (error) {
177
- throw this.handleError("load", error);
179
+ throw this.handleError("load", error, "load_error");
178
180
  }
179
181
  this.teardownMusic();
180
182
  const audio = new Audio();
181
183
  audio.crossOrigin = "anonymous";
182
184
  audio.src = url;
183
- audio.loop = true;
185
+ audio.loop = this.trackLoop;
184
186
  this.audioEl = audio;
185
- this.setState({ hasTrack: true, loadError: null });
187
+ this.setState({ hasTrack: true, loadError: null, playbackError: null });
186
188
  const source = ctx.createMediaElementSource(audio);
187
189
  source.connect(this.musicAnalyser);
188
190
  this.musicSource = source;
191
+ }
192
+ async play() {
193
+ const audio = this.audioEl;
194
+ if (!audio) return;
189
195
  try {
190
196
  await audio.play();
191
- this.setState({ isPlaying: true, loadError: null });
197
+ this.setState({ isPlaying: true, playbackError: null });
192
198
  this.options.onPlay?.();
193
199
  } catch (error) {
194
- throw this.handleError("load", error, "load_error");
200
+ throw this.handleError("playback", error, "playback_error");
195
201
  }
196
202
  }
197
- togglePlayPause() {
203
+ pause() {
204
+ const audio = this.audioEl;
205
+ if (!audio || audio.paused) return;
206
+ audio.pause();
207
+ this.setState({ isPlaying: false });
208
+ this.options.onPause?.();
209
+ }
210
+ async togglePlayPause() {
198
211
  const audio = this.audioEl;
199
212
  if (!audio) return;
200
213
  if (audio.paused) {
201
- void audio.play().then(() => {
202
- this.setState({ isPlaying: true, loadError: null });
203
- this.options.onPlay?.();
204
- }).catch((error) => {
205
- this.handleError("load", error, "playback_error");
206
- });
214
+ await this.play();
207
215
  return;
208
216
  }
209
- audio.pause();
210
- this.setState({ isPlaying: false });
211
- this.options.onPause?.();
217
+ this.pause();
218
+ }
219
+ setLoop(loop) {
220
+ this.trackLoop = loop;
221
+ if (this.audioEl) this.audioEl.loop = loop;
222
+ }
223
+ seek(seconds) {
224
+ if (!Number.isFinite(seconds) || seconds < 0) {
225
+ throw new AudioBandsError(
226
+ "config",
227
+ "invalid_config",
228
+ "seek time must be a finite number greater than or equal to 0"
229
+ );
230
+ }
231
+ if (!this.audioEl) return;
232
+ const duration = Number.isFinite(this.audioEl.duration) ? this.audioEl.duration : null;
233
+ this.audioEl.currentTime = duration === null ? seconds : Math.min(seconds, duration);
234
+ }
235
+ getDuration() {
236
+ if (!this.audioEl) return null;
237
+ return Number.isFinite(this.audioEl.duration) ? this.audioEl.duration : null;
238
+ }
239
+ getCurrentTime() {
240
+ if (!this.audioEl) return null;
241
+ return Number.isFinite(this.audioEl.currentTime) ? this.audioEl.currentTime : null;
212
242
  }
213
243
  async enableMic() {
214
244
  let ctx;
@@ -267,6 +297,16 @@ var AudioBands = class {
267
297
  getWaveform(source = "music") {
268
298
  return this.readWaveformData(source);
269
299
  }
300
+ snapshot(source = "music") {
301
+ const fft = this.readFrequencyData(source);
302
+ const waveform = this.readWaveformData(source);
303
+ return {
304
+ bands: fft ? computeBands(fft, this.classicRanges) : { ...ZERO },
305
+ customBands: fft ? computeCustomBands(fft, this.customBandRanges) : computeCustomBands(new Uint8Array(1), this.customBandRanges),
306
+ fft,
307
+ waveform
308
+ };
309
+ }
270
310
  destroy() {
271
311
  if (this.destroyed) return;
272
312
  this.teardownMusic();
@@ -334,16 +374,19 @@ var AudioBands = class {
334
374
  analyser.smoothingTimeConstant = config.smoothingTimeConstant;
335
375
  return analyser;
336
376
  }
337
- handleError(kind, error, fallbackCode = kind === "mic" ? "mic_error" : "load_error") {
377
+ handleError(kind, error, fallbackCode = kind === "mic" ? "mic_error" : kind === "playback" ? "playback_error" : "load_error") {
338
378
  const wrapped = error instanceof AudioBandsError ? error : new AudioBandsError(
339
379
  kind,
340
380
  fallbackCode,
341
- kind === "mic" ? "Failed to access microphone input" : "Failed to load or play audio track",
381
+ kind === "mic" ? "Failed to access microphone input" : kind === "playback" ? "Failed to play audio track" : "Failed to load audio track",
342
382
  error
343
383
  );
344
384
  if (kind === "load") {
345
385
  this.setState({ isPlaying: false, loadError: wrapped });
346
386
  this.options.onLoadError?.(wrapped);
387
+ } else if (kind === "playback") {
388
+ this.setState({ isPlaying: false, playbackError: wrapped });
389
+ this.options.onPlaybackError?.(wrapped);
347
390
  } else {
348
391
  this.setState({ micActive: false, micError: wrapped });
349
392
  this.options.onMicError?.(wrapped);
@@ -374,7 +417,11 @@ var AudioBands = class {
374
417
  }
375
418
  this.musicSource = null;
376
419
  this.musicWaveformData = this.musicAnalyser ? new Uint8Array(this.musicAnalyser.fftSize) : null;
377
- this.setState({ isPlaying: false, hasTrack: false });
420
+ this.setState({
421
+ isPlaying: false,
422
+ hasTrack: false,
423
+ playbackError: null
424
+ });
378
425
  }
379
426
  };
380
427
  // Annotate the CommonJS export names for ESM import in node: