@arraypress/waveform-player 1.7.2 → 1.8.1
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 +176 -377
- package/dist/waveform-player.cjs +2208 -0
- package/dist/waveform-player.cjs.map +7 -0
- package/dist/waveform-player.css +1 -1
- package/dist/waveform-player.esm.js +8 -8
- package/dist/waveform-player.esm.js.map +7 -0
- package/dist/waveform-player.js +553 -275
- package/dist/waveform-player.min.js +8 -8
- package/dist/waveform-player.min.js.map +7 -0
- package/index.d.ts +344 -0
- package/package.json +18 -8
- package/src/css/waveform-player.css +21 -3
- package/src/js/audio.js +61 -25
- package/src/js/bpm.js +26 -5
- package/src/js/core.js +417 -185
- package/src/js/drawing.js +208 -44
- package/src/js/index.js +56 -11
- package/src/js/themes.js +88 -47
- package/src/js/utils.js +231 -65
package/index.d.ts
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for @arraypress/waveform-player
|
|
3
|
+
* Project: https://github.com/arraypress/waveform-player
|
|
4
|
+
*
|
|
5
|
+
* Hand-authored to mirror the runtime option surface and public API. This is
|
|
6
|
+
* the single source of truth for the library's types — the React and Astro
|
|
7
|
+
* wrappers re-export from here rather than re-declaring the option list.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Visual style of the waveform.
|
|
12
|
+
*
|
|
13
|
+
* - `bars` — vertical bars from the baseline up
|
|
14
|
+
* - `mirror` — symmetrical bars mirrored around the centre line (default)
|
|
15
|
+
* - `line` — connected line graph
|
|
16
|
+
* - `blocks` — chunky square blocks
|
|
17
|
+
* - `dots` — dotted plot
|
|
18
|
+
* - `seekbar` — minimal seek bar with no peak detail
|
|
19
|
+
*/
|
|
20
|
+
export type WaveformStyle = 'bars' | 'mirror' | 'line' | 'blocks' | 'dots' | 'seekbar';
|
|
21
|
+
|
|
22
|
+
/** Forced colour scheme. `null` (default) auto-detects from the page theme and `prefers-color-scheme`. */
|
|
23
|
+
export type ColorPreset = 'dark' | 'light' | null;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* How the player handles audio.
|
|
27
|
+
*
|
|
28
|
+
* - `self` — the player owns an `<audio>` element and plays the URL itself (default).
|
|
29
|
+
* - `external` — visualisation-only; `play()`/`pause()`/seek dispatch
|
|
30
|
+
* `waveformplayer:request-*` events for an external controller, which drives
|
|
31
|
+
* the visualisation back via {@link WaveformPlayer.setPlayingState} / {@link WaveformPlayer.setProgress}.
|
|
32
|
+
*/
|
|
33
|
+
export type AudioMode = 'self' | 'external';
|
|
34
|
+
|
|
35
|
+
/** Browser preload hint for the underlying `<audio>` element. */
|
|
36
|
+
export type AudioPreload = 'auto' | 'metadata' | 'none';
|
|
37
|
+
|
|
38
|
+
/** Vertical alignment of the play button relative to the waveform. */
|
|
39
|
+
export type ButtonAlign = 'auto' | 'top' | 'center' | 'bottom';
|
|
40
|
+
|
|
41
|
+
/** A clickable chapter marker rendered on top of the waveform. */
|
|
42
|
+
export interface WaveformMarker {
|
|
43
|
+
/** Time in seconds at which the marker appears. */
|
|
44
|
+
time: number;
|
|
45
|
+
/** Short label shown as a tooltip / accessible name. */
|
|
46
|
+
label: string;
|
|
47
|
+
/** Optional override colour (any CSS colour string). */
|
|
48
|
+
color?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Pre-computed waveform peaks, OR a pointer to them.
|
|
53
|
+
*
|
|
54
|
+
* - `number[]` — inline array of peak amplitudes (0..1)
|
|
55
|
+
* - `string` (.json URL) — JSON file URL the library will `fetch()`
|
|
56
|
+
* - `string` (JSON array) — inline JSON string the library will parse
|
|
57
|
+
* - `null` / omitted — the library decodes the audio with the Web Audio API at load time
|
|
58
|
+
*/
|
|
59
|
+
export type WaveformPeaks = number[] | string | null;
|
|
60
|
+
|
|
61
|
+
/** `onTimeUpdate` fires with the same `(currentTime, duration, player)` order in both audio modes. */
|
|
62
|
+
export type WaveformTimeUpdateHandler = (currentTime: number, duration: number, player: WaveformPlayer) => void;
|
|
63
|
+
/** Lifecycle callback receiving the player instance. */
|
|
64
|
+
export type WaveformPlayerHandler = (player: WaveformPlayer) => void;
|
|
65
|
+
/** Error callback receiving the thrown error and the player instance. */
|
|
66
|
+
export type WaveformErrorHandler = (error: unknown, player: WaveformPlayer) => void;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Construction options for {@link WaveformPlayer}. Every field is optional; the
|
|
70
|
+
* library fills in defaults. The same keys can be supplied as `data-*`
|
|
71
|
+
* attributes on the host element for the zero-build drop-in.
|
|
72
|
+
*/
|
|
73
|
+
export interface WaveformPlayerOptions {
|
|
74
|
+
// ── Audio source ──────────────────────────────────────────────
|
|
75
|
+
/** Audio file URL. */
|
|
76
|
+
url?: string;
|
|
77
|
+
/** Shorthand alias for {@link url} (`data-src`). The canonical name wins if both are set. */
|
|
78
|
+
src?: string;
|
|
79
|
+
/** Waveform height in pixels. @default 60 */
|
|
80
|
+
height?: number;
|
|
81
|
+
/** Number of peak samples to extract when decoding. @default 200 */
|
|
82
|
+
samples?: number;
|
|
83
|
+
/** `<audio>` preload hint. @default 'metadata' */
|
|
84
|
+
preload?: AudioPreload;
|
|
85
|
+
/** Whether the player owns its `<audio>` or delegates to an external controller. @default 'self' */
|
|
86
|
+
audioMode?: AudioMode;
|
|
87
|
+
/** Pre-computed peaks (array, .json URL, or JSON string). Skips Web Audio decoding when provided. */
|
|
88
|
+
waveform?: WaveformPeaks;
|
|
89
|
+
|
|
90
|
+
// ── Waveform visualisation ────────────────────────────────────
|
|
91
|
+
/** Visual style. @default 'mirror' */
|
|
92
|
+
waveformStyle?: WaveformStyle;
|
|
93
|
+
/** Shorthand alias for {@link waveformStyle} (`data-style`). The canonical name wins if both are set. */
|
|
94
|
+
style?: WaveformStyle;
|
|
95
|
+
/** Bar width in pixels (style-dependent default). */
|
|
96
|
+
barWidth?: number;
|
|
97
|
+
/** Gap between bars in pixels (style-dependent default). */
|
|
98
|
+
barSpacing?: number;
|
|
99
|
+
/** Rounded bar-cap radius in pixels (bars/mirror). `0` = square. @default 0 */
|
|
100
|
+
barRadius?: number;
|
|
101
|
+
|
|
102
|
+
// ── Colours ───────────────────────────────────────────────────
|
|
103
|
+
/** Force a colour preset, or `null` to auto-detect. @default null */
|
|
104
|
+
colorPreset?: ColorPreset;
|
|
105
|
+
/**
|
|
106
|
+
* Unplayed waveform colour (each `null` = use the preset). Pass an array of
|
|
107
|
+
* CSS colour stops for a vertical gradient, e.g. `['#fafafa', '#71717a']`.
|
|
108
|
+
*/
|
|
109
|
+
waveformColor?: string | string[] | null;
|
|
110
|
+
/** Played-through colour. Also accepts an array of stops for a gradient. */
|
|
111
|
+
progressColor?: string | string[] | null;
|
|
112
|
+
buttonColor?: string | null;
|
|
113
|
+
buttonHoverColor?: string | null;
|
|
114
|
+
textColor?: string | null;
|
|
115
|
+
textSecondaryColor?: string | null;
|
|
116
|
+
backgroundColor?: string | null;
|
|
117
|
+
borderColor?: string | null;
|
|
118
|
+
|
|
119
|
+
// ── Playback ──────────────────────────────────────────────────
|
|
120
|
+
/** Initial playback rate. @default 1 */
|
|
121
|
+
playbackRate?: number;
|
|
122
|
+
/** Show the playback-speed control. @default false */
|
|
123
|
+
showPlaybackSpeed?: boolean;
|
|
124
|
+
/** Selectable playback rates. @default [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2] */
|
|
125
|
+
playbackRates?: number[];
|
|
126
|
+
|
|
127
|
+
// ── Layout / UI toggles ───────────────────────────────────────
|
|
128
|
+
/** Play-button alignment. @default 'auto' */
|
|
129
|
+
buttonAlign?: ButtonAlign;
|
|
130
|
+
/** Show transport controls. @default true */
|
|
131
|
+
showControls?: boolean;
|
|
132
|
+
/** Show the info (title/subtitle) block. @default true */
|
|
133
|
+
showInfo?: boolean;
|
|
134
|
+
/** Show current/total time. @default true */
|
|
135
|
+
showTime?: boolean;
|
|
136
|
+
/** Show a time tooltip on hover. @default false */
|
|
137
|
+
showHoverTime?: boolean;
|
|
138
|
+
/** Show detected BPM. @default false */
|
|
139
|
+
showBPM?: boolean;
|
|
140
|
+
|
|
141
|
+
// ── Behaviour ─────────────────────────────────────────────────
|
|
142
|
+
/** Begin playback on load. @default false */
|
|
143
|
+
autoplay?: boolean;
|
|
144
|
+
/** Pause every other player instance when this one plays. @default true */
|
|
145
|
+
singlePlay?: boolean;
|
|
146
|
+
/** Start playing when the user seeks. @default true */
|
|
147
|
+
playOnSeek?: boolean;
|
|
148
|
+
/** Register system Media Session controls (self mode only). @default true */
|
|
149
|
+
enableMediaSession?: boolean;
|
|
150
|
+
|
|
151
|
+
// ── Markers ───────────────────────────────────────────────────
|
|
152
|
+
/** Chapter markers. @default [] */
|
|
153
|
+
markers?: WaveformMarker[];
|
|
154
|
+
/** Render markers. @default true */
|
|
155
|
+
showMarkers?: boolean;
|
|
156
|
+
|
|
157
|
+
// ── Accessibility ─────────────────────────────────────────────
|
|
158
|
+
/** Expose the waveform as a keyboard-operable ARIA slider. @default true */
|
|
159
|
+
accessibleSeek?: boolean;
|
|
160
|
+
/** Accessible name for the seek slider (falls back to the title, then `'Seek'`). @default null */
|
|
161
|
+
seekLabel?: string | null;
|
|
162
|
+
|
|
163
|
+
// ── Content metadata ──────────────────────────────────────────
|
|
164
|
+
title?: string | null;
|
|
165
|
+
subtitle?: string | null;
|
|
166
|
+
artwork?: string | null;
|
|
167
|
+
album?: string;
|
|
168
|
+
/** Message shown when audio fails to load. @default 'Unable to load audio' */
|
|
169
|
+
errorText?: string;
|
|
170
|
+
|
|
171
|
+
// ── Icons (raw SVG markup) ────────────────────────────────────
|
|
172
|
+
playIcon?: string;
|
|
173
|
+
pauseIcon?: string;
|
|
174
|
+
|
|
175
|
+
// ── Callbacks ─────────────────────────────────────────────────
|
|
176
|
+
onLoad?: WaveformPlayerHandler | null;
|
|
177
|
+
onPlay?: WaveformPlayerHandler | null;
|
|
178
|
+
onPause?: WaveformPlayerHandler | null;
|
|
179
|
+
onEnd?: WaveformPlayerHandler | null;
|
|
180
|
+
onError?: WaveformErrorHandler | null;
|
|
181
|
+
onTimeUpdate?: WaveformTimeUpdateHandler | null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** `detail` of `waveformplayer:play | pause | ready | destroy`. */
|
|
185
|
+
export interface WaveformLifecycleEventDetail {
|
|
186
|
+
player: WaveformPlayer;
|
|
187
|
+
url: string;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** `detail` of `waveformplayer:ended` — lifecycle plus the final time. */
|
|
191
|
+
export interface WaveformEndedEventDetail extends WaveformLifecycleEventDetail {
|
|
192
|
+
currentTime: number;
|
|
193
|
+
duration: number;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** `detail` of `waveformplayer:timeupdate`. */
|
|
197
|
+
export interface WaveformTimeUpdateEventDetail {
|
|
198
|
+
player: WaveformPlayer;
|
|
199
|
+
currentTime: number;
|
|
200
|
+
duration: number;
|
|
201
|
+
/** Progress as a 0..1 fraction. */
|
|
202
|
+
progress: number;
|
|
203
|
+
url: string;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Track metadata carried by the external-mode `request-*` events. */
|
|
207
|
+
export interface WaveformTrackDetail {
|
|
208
|
+
url: string;
|
|
209
|
+
title: string | null;
|
|
210
|
+
subtitle: string | null;
|
|
211
|
+
artist?: string;
|
|
212
|
+
artwork: string | null;
|
|
213
|
+
/** Chapter markers for the track (forwarded so controllers don't re-fetch). */
|
|
214
|
+
markers?: WaveformMarker[];
|
|
215
|
+
/** Pre-computed peaks for the track, if any. */
|
|
216
|
+
waveform?: WaveformPeaks;
|
|
217
|
+
id: string;
|
|
218
|
+
player: WaveformPlayer;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** `detail` of `waveformplayer:request-play | request-pause`. */
|
|
222
|
+
export type WaveformRequestEventDetail = WaveformTrackDetail;
|
|
223
|
+
|
|
224
|
+
/** `detail` of `waveformplayer:request-seek` — adds the requested position. */
|
|
225
|
+
export interface WaveformRequestSeekEventDetail extends WaveformTrackDetail {
|
|
226
|
+
/** Requested position as a 0..1 fraction of total duration. */
|
|
227
|
+
percent: number;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Map of every custom event the player dispatches on its container (bubbling). */
|
|
231
|
+
export interface WaveformPlayerEventMap {
|
|
232
|
+
'waveformplayer:ready': CustomEvent<WaveformLifecycleEventDetail>;
|
|
233
|
+
'waveformplayer:play': CustomEvent<WaveformLifecycleEventDetail>;
|
|
234
|
+
'waveformplayer:pause': CustomEvent<WaveformLifecycleEventDetail>;
|
|
235
|
+
'waveformplayer:destroy': CustomEvent<WaveformLifecycleEventDetail>;
|
|
236
|
+
'waveformplayer:ended': CustomEvent<WaveformEndedEventDetail>;
|
|
237
|
+
'waveformplayer:timeupdate': CustomEvent<WaveformTimeUpdateEventDetail>;
|
|
238
|
+
'waveformplayer:request-play': CustomEvent<WaveformRequestEventDetail>;
|
|
239
|
+
'waveformplayer:request-pause': CustomEvent<WaveformRequestEventDetail>;
|
|
240
|
+
'waveformplayer:request-seek': CustomEvent<WaveformRequestSeekEventDetail>;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Modern audio player with waveform visualisation.
|
|
245
|
+
*
|
|
246
|
+
* @example
|
|
247
|
+
* ```ts
|
|
248
|
+
* import WaveformPlayer from '@arraypress/waveform-player';
|
|
249
|
+
* const player = new WaveformPlayer('#player', { url: '/track.mp3' });
|
|
250
|
+
* ```
|
|
251
|
+
*/
|
|
252
|
+
export declare class WaveformPlayer {
|
|
253
|
+
/**
|
|
254
|
+
* @param container Host element, or a CSS selector / element id resolving to one.
|
|
255
|
+
* @param options Player options (also readable from `data-*` attributes on the element).
|
|
256
|
+
*/
|
|
257
|
+
constructor(container: string | HTMLElement, options?: WaveformPlayerOptions);
|
|
258
|
+
|
|
259
|
+
/** Resolved options after merging defaults, data-attributes, and constructor options. */
|
|
260
|
+
readonly options: Required<WaveformPlayerOptions>;
|
|
261
|
+
/** Unique instance id (the host element id, or a generated one). */
|
|
262
|
+
readonly id: string;
|
|
263
|
+
/** Host element. */
|
|
264
|
+
readonly container: HTMLElement;
|
|
265
|
+
/** Current progress as a 0..1 fraction. */
|
|
266
|
+
progress: number;
|
|
267
|
+
/** Whether playback is active. */
|
|
268
|
+
isPlaying: boolean;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Start playback. In `self` mode returns the native `HTMLMediaElement.play()`
|
|
272
|
+
* promise; in `external` mode dispatches `waveformplayer:request-play` and returns `undefined`.
|
|
273
|
+
*/
|
|
274
|
+
play(): Promise<void> | undefined;
|
|
275
|
+
/** Pause playback (or dispatch `request-pause` in external mode). */
|
|
276
|
+
pause(): void;
|
|
277
|
+
/** Toggle play / pause. */
|
|
278
|
+
togglePlay(): void;
|
|
279
|
+
/** Load (or replace) the audio URL and regenerate the waveform. */
|
|
280
|
+
load(url: string): Promise<void>;
|
|
281
|
+
/** Load a new track without re-instantiating; updates metadata then plays. */
|
|
282
|
+
loadTrack(url: string, title?: string | null, subtitle?: string | null, options?: WaveformPlayerOptions): Promise<void>;
|
|
283
|
+
/** Seek to an absolute time in seconds (self mode). */
|
|
284
|
+
seekTo(seconds: number): void;
|
|
285
|
+
/** Seek to a fraction of total duration, 0..1 (self mode). */
|
|
286
|
+
seekToPercent(percent: number): void;
|
|
287
|
+
/** Set output volume, 0..1 (self mode). */
|
|
288
|
+
setVolume(volume: number): void;
|
|
289
|
+
/** Set the playback rate (self mode). */
|
|
290
|
+
setPlaybackRate(rate: number): void;
|
|
291
|
+
/** Provide pre-computed peaks directly. */
|
|
292
|
+
setWaveformData(data: WaveformPeaks): void;
|
|
293
|
+
/** Highlight the marker at `index` (clears the rest); pass `null` to clear all. */
|
|
294
|
+
setActiveMarker(index: number | null): void;
|
|
295
|
+
/** External mode: push play/pause state so the visualisation reflects your audio source. */
|
|
296
|
+
setPlayingState(playing: boolean): void;
|
|
297
|
+
/** External mode: push the current position so the progress overlay advances. */
|
|
298
|
+
setProgress(currentTime: number, duration: number): void;
|
|
299
|
+
/** Tear down the player: stops audio, removes all listeners, clears the container. */
|
|
300
|
+
destroy(): void;
|
|
301
|
+
|
|
302
|
+
/** Map of live instances keyed by id. */
|
|
303
|
+
static readonly instances: Map<string, WaveformPlayer>;
|
|
304
|
+
/** The instance currently playing, if any. */
|
|
305
|
+
static currentlyPlaying: WaveformPlayer | null;
|
|
306
|
+
/** Look up an instance by id, element, or element id. */
|
|
307
|
+
static getInstance(idOrElement: string | HTMLElement): WaveformPlayer | undefined;
|
|
308
|
+
/** All live instances. */
|
|
309
|
+
static getAllInstances(): WaveformPlayer[];
|
|
310
|
+
/** Destroy every live instance. */
|
|
311
|
+
static destroyAll(): void;
|
|
312
|
+
/** Decode an audio URL to peak data without constructing a player. */
|
|
313
|
+
static generateWaveformData(url: string, samples?: number): Promise<{ peaks: number[]; bpm: number | null }>;
|
|
314
|
+
/** Convention helper: derive the sibling `.json` peaks URL for an audio URL. */
|
|
315
|
+
static getPeaksUrl(audioUrl: string): string;
|
|
316
|
+
/** Scan the document for `[data-waveform-player]` elements and initialise them. */
|
|
317
|
+
static init(): void;
|
|
318
|
+
/**
|
|
319
|
+
* Pure helper functions exposed as a single source of truth so consumers
|
|
320
|
+
* (e.g. `@arraypress/waveform-bar`) can reuse them instead of shipping
|
|
321
|
+
* divergent copies.
|
|
322
|
+
*/
|
|
323
|
+
static readonly utils: {
|
|
324
|
+
/** Format seconds as `M:SS` (or `H:MM:SS` past an hour). */
|
|
325
|
+
formatTime(seconds: number): string;
|
|
326
|
+
/** Derive a display title from a URL's filename. */
|
|
327
|
+
extractTitleFromUrl(url: string): string;
|
|
328
|
+
/** Escape a value for safe interpolation into HTML. */
|
|
329
|
+
escapeHtml(str: unknown): string;
|
|
330
|
+
/** Whether a URL uses a safe (`http`/`https`/relative) scheme. */
|
|
331
|
+
isSafeHref(url: string): boolean;
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export default WaveformPlayer;
|
|
336
|
+
|
|
337
|
+
declare global {
|
|
338
|
+
interface Window {
|
|
339
|
+
/** Global constructor exposed by the IIFE/UMD build for `<script>` usage. */
|
|
340
|
+
WaveformPlayer: typeof WaveformPlayer;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
interface HTMLElementEventMap extends WaveformPlayerEventMap {}
|
|
344
|
+
}
|
package/package.json
CHANGED
|
@@ -1,37 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arraypress/waveform-player",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.1",
|
|
4
4
|
"description": "Lightweight, customizable audio player with waveform visualization",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"
|
|
6
|
+
"types": "./index.d.ts",
|
|
7
|
+
"main": "dist/waveform-player.cjs",
|
|
7
8
|
"module": "dist/waveform-player.esm.js",
|
|
8
9
|
"unpkg": "dist/waveform-player.min.js",
|
|
9
10
|
"exports": {
|
|
10
11
|
".": {
|
|
12
|
+
"types": "./index.d.ts",
|
|
11
13
|
"import": "./dist/waveform-player.esm.js",
|
|
14
|
+
"require": "./dist/waveform-player.cjs",
|
|
12
15
|
"default": "./dist/waveform-player.esm.js"
|
|
13
16
|
},
|
|
17
|
+
"./styles.css": "./dist/waveform-player.css",
|
|
14
18
|
"./dist/*": "./dist/*",
|
|
15
19
|
"./package.json": "./package.json"
|
|
16
20
|
},
|
|
17
21
|
"files": [
|
|
18
22
|
"dist/",
|
|
19
23
|
"src/",
|
|
24
|
+
"index.d.ts",
|
|
20
25
|
"README.md",
|
|
21
26
|
"LICENSE"
|
|
22
27
|
],
|
|
23
28
|
"scripts": {
|
|
24
|
-
"build": "npm run build:css && npm run build:iife && npm run build:esm && npm run build:min",
|
|
29
|
+
"build": "npm run build:css && npm run build:iife && npm run build:esm && npm run build:cjs && npm run build:min",
|
|
25
30
|
"build:css": "esbuild src/css/waveform-player.css --minify --outfile=dist/waveform-player.css",
|
|
26
31
|
"build:iife": "esbuild src/js/index.js --bundle --format=iife --outfile=dist/waveform-player.js",
|
|
27
|
-
"build:min": "esbuild src/js/index.js --bundle --format=iife --outfile=dist/waveform-player.min.js --minify",
|
|
28
|
-
"build:esm": "esbuild src/js/index.js --bundle --format=esm --outfile=dist/waveform-player.esm.js --minify",
|
|
32
|
+
"build:min": "esbuild src/js/index.js --bundle --format=iife --outfile=dist/waveform-player.min.js --minify --sourcemap=external",
|
|
33
|
+
"build:esm": "esbuild src/js/index.js --bundle --format=esm --outfile=dist/waveform-player.esm.js --minify --sourcemap=external",
|
|
34
|
+
"build:cjs": "esbuild src/js/index.js --bundle --format=cjs --outfile=dist/waveform-player.cjs --sourcemap=external",
|
|
29
35
|
"dev": "npm run build:css && esbuild src/js/index.js --bundle --format=iife --outfile=dist/waveform-player.js --watch",
|
|
30
36
|
"dev:css": "esbuild src/css/waveform-player.css --outfile=dist/waveform-player.css --watch",
|
|
31
37
|
"dev:demo": "npm run build:css && concurrently \"npm run dev\" \"npm run dev:css\"",
|
|
32
38
|
"size": "npm run build:min && npm run build:css && echo 'JS:' && gzip -c dist/waveform-player.min.js | wc -c && echo 'CSS:' && gzip -c dist/waveform-player.css | wc -c",
|
|
33
39
|
"serve": "npx http-server -p 8000",
|
|
34
|
-
"
|
|
40
|
+
"test": "vitest run",
|
|
41
|
+
"test:watch": "vitest",
|
|
42
|
+
"prepublishOnly": "npm run test && npm run build"
|
|
35
43
|
},
|
|
36
44
|
"keywords": [
|
|
37
45
|
"audio",
|
|
@@ -75,7 +83,9 @@
|
|
|
75
83
|
"*.css"
|
|
76
84
|
],
|
|
77
85
|
"devDependencies": {
|
|
78
|
-
"concurrently": "^
|
|
79
|
-
"esbuild": "^0.
|
|
86
|
+
"concurrently": "^10.0.3",
|
|
87
|
+
"esbuild": "^0.28.1",
|
|
88
|
+
"jsdom": "^29.1.1",
|
|
89
|
+
"vitest": "^4.1.9"
|
|
80
90
|
}
|
|
81
91
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
.waveform-player {
|
|
3
3
|
font-family: inherit;
|
|
4
4
|
color: inherit;
|
|
5
|
-
line-height: 1.4;
|
|
5
|
+
line-height: var(--waveform-line-height, 1.4);
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
.waveform-player * {
|
|
@@ -13,14 +13,14 @@
|
|
|
13
13
|
.waveform-body {
|
|
14
14
|
display: flex;
|
|
15
15
|
flex-direction: column;
|
|
16
|
-
gap: 8px;
|
|
16
|
+
gap: var(--waveform-body-gap, 8px);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
/* Track row */
|
|
20
20
|
.waveform-track {
|
|
21
21
|
display: flex;
|
|
22
22
|
align-items: center;
|
|
23
|
-
gap: 12px;
|
|
23
|
+
gap: var(--waveform-track-gap, 12px);
|
|
24
24
|
position: relative;
|
|
25
25
|
}
|
|
26
26
|
|
|
@@ -201,6 +201,13 @@
|
|
|
201
201
|
z-index: 20;
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
/* Active marker — set via player.setActiveMarker(index). */
|
|
205
|
+
.waveform-marker.active {
|
|
206
|
+
width: 4px;
|
|
207
|
+
background: currentColor;
|
|
208
|
+
z-index: 10;
|
|
209
|
+
}
|
|
210
|
+
|
|
204
211
|
/* Marker tooltip */
|
|
205
212
|
.waveform-marker-tooltip {
|
|
206
213
|
position: absolute;
|
|
@@ -364,4 +371,15 @@
|
|
|
364
371
|
.waveform-bpm {
|
|
365
372
|
font-size: 10px;
|
|
366
373
|
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/* Respect users who prefer reduced motion — neutralize transitions/animations. */
|
|
377
|
+
@media (prefers-reduced-motion: reduce) {
|
|
378
|
+
.waveform-player *,
|
|
379
|
+
.waveform-player *::before,
|
|
380
|
+
.waveform-player *::after {
|
|
381
|
+
transition-duration: 0.01ms !important;
|
|
382
|
+
animation-duration: 0.01ms !important;
|
|
383
|
+
animation-iteration-count: 1 !important;
|
|
384
|
+
}
|
|
367
385
|
}
|
package/src/js/audio.js
CHANGED
|
@@ -4,12 +4,21 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import {detectBPM} from './bpm.js';
|
|
7
|
+
import {clamp} from './utils.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
|
-
* Extract peaks from audio buffer for waveform visualization
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Extract peaks from a decoded audio buffer for waveform visualization.
|
|
11
|
+
*
|
|
12
|
+
* Divides the buffer into `samples` equal-width windows and, within each
|
|
13
|
+
* window, finds the largest absolute amplitude. To keep large files fast the
|
|
14
|
+
* inner loop strides through every 10th frame (`sampleStep`) rather than
|
|
15
|
+
* inspecting every frame. Across multiple channels the per-window peaks are
|
|
16
|
+
* merged by taking the loudest channel, then the whole array is normalized so
|
|
17
|
+
* the maximum peak becomes 1 (a silent buffer is returned unscaled).
|
|
18
|
+
*
|
|
19
|
+
* @param {AudioBuffer} buffer - Decoded audio buffer to analyse.
|
|
20
|
+
* @param {number} [samples=200] - Number of peak windows (output array length).
|
|
21
|
+
* @returns {number[]} Array of `samples` normalized peak values in the 0-1 range.
|
|
13
22
|
*/
|
|
14
23
|
export function extractPeaks(buffer, samples = 200) {
|
|
15
24
|
const sampleSize = buffer.length / samples;
|
|
@@ -47,15 +56,30 @@ export function extractPeaks(buffer, samples = 200) {
|
|
|
47
56
|
}
|
|
48
57
|
|
|
49
58
|
/**
|
|
50
|
-
* Generate waveform data
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* @
|
|
54
|
-
*
|
|
59
|
+
* Generate waveform data by fetching and decoding an audio file at a URL.
|
|
60
|
+
*
|
|
61
|
+
* Fetches the URL, decodes it through a short-lived AudioContext, runs
|
|
62
|
+
* {@link extractPeaks} followed by {@link normalizePeaks}, and optionally
|
|
63
|
+
* detects the track's BPM. The AudioContext is created lazily and always
|
|
64
|
+
* closed in the `finally` block so failed decodes never leak one (browsers
|
|
65
|
+
* hard-cap the number of live contexts). Errors are logged and re-thrown so
|
|
66
|
+
* callers can fall back to a placeholder waveform.
|
|
67
|
+
*
|
|
68
|
+
* @param {string} url - Audio file URL to fetch and decode.
|
|
69
|
+
* @param {number} [samples=200] - Number of peak windows to extract.
|
|
70
|
+
* @param {boolean} [shouldDetectBPM=false] - Whether to run BPM detection on the decoded buffer.
|
|
71
|
+
* @returns {Promise<{peaks: number[], bpm: (number|null)}>} Resolves with the
|
|
72
|
+
* normalized peaks and the detected BPM (`null` when detection is disabled or fails).
|
|
73
|
+
* @throws {Error} Re-throws any fetch/decode error after logging it.
|
|
55
74
|
*/
|
|
56
|
-
export async function generateWaveform(url, samples = 200, shouldDetectBPM = false) {
|
|
75
|
+
export async function generateWaveform(url, samples = 200, shouldDetectBPM = false) {
|
|
76
|
+
// Created lazily so the finally block can always close it — browsers
|
|
77
|
+
// hard-cap live AudioContexts (~6 in Chrome), so leaking one per failed
|
|
78
|
+
// decode would break every subsequent player on the page.
|
|
79
|
+
let audioContext;
|
|
57
80
|
try {
|
|
58
|
-
const
|
|
81
|
+
const AudioCtx = window.AudioContext || /** @type {any} */ (window).webkitAudioContext;
|
|
82
|
+
audioContext = new AudioCtx();
|
|
59
83
|
const response = await fetch(url);
|
|
60
84
|
const arrayBuffer = await response.arrayBuffer();
|
|
61
85
|
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
|
@@ -66,38 +90,50 @@ export async function generateWaveform(url, samples = 200, shouldDetectBPM = fal
|
|
|
66
90
|
peaks = normalizePeaks(peaks);
|
|
67
91
|
|
|
68
92
|
let bpm = null;
|
|
69
|
-
if (shouldDetectBPM) {
|
|
70
|
-
bpm =
|
|
93
|
+
if (shouldDetectBPM) {
|
|
94
|
+
bpm = detectBPM(audioBuffer); // synchronous — returns number|null
|
|
71
95
|
}
|
|
72
96
|
|
|
73
|
-
audioContext.close();
|
|
74
97
|
return {peaks, bpm};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
|
|
98
|
+
} finally {
|
|
99
|
+
// Error (if any) propagates to the caller, which decides how to log /
|
|
100
|
+
// recover; the context is always closed either way.
|
|
101
|
+
if (audioContext) audioContext.close();
|
|
78
102
|
}
|
|
79
103
|
}
|
|
80
104
|
|
|
81
105
|
/**
|
|
82
|
-
* Generate placeholder waveform
|
|
83
|
-
*
|
|
84
|
-
*
|
|
106
|
+
* Generate a synthetic placeholder waveform for use before (or instead of)
|
|
107
|
+
* real peak data is available.
|
|
108
|
+
*
|
|
109
|
+
* Each bar combines a random base height (0.3-0.8) with a slow sinusoidal
|
|
110
|
+
* variation across the array, clamped to the 0.1-1 range so the result always
|
|
111
|
+
* looks like a plausible waveform rather than pure noise.
|
|
112
|
+
*
|
|
113
|
+
* @param {number} [samples=200] - Number of bars (output array length).
|
|
114
|
+
* @returns {number[]} Array of `samples` pseudo-random peak values in the 0.1-1 range.
|
|
85
115
|
*/
|
|
86
116
|
export function generatePlaceholderWaveform(samples = 200) {
|
|
87
117
|
const data = [];
|
|
88
118
|
for (let i = 0; i < samples; i++) {
|
|
89
119
|
const base = Math.random() * 0.5 + 0.3;
|
|
90
120
|
const variation = Math.sin(i / samples * Math.PI * 4) * 0.2;
|
|
91
|
-
data.push(
|
|
121
|
+
data.push(clamp(base + variation, 0.1, 1));
|
|
92
122
|
}
|
|
93
123
|
return data;
|
|
94
124
|
}
|
|
95
125
|
|
|
96
126
|
/**
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
127
|
+
* Scale peak values so quiet tracks fill the available height consistently.
|
|
128
|
+
*
|
|
129
|
+
* Finds the loudest peak and, only when it is non-zero yet below `targetMax`,
|
|
130
|
+
* scales every peak proportionally so the maximum lands on `targetMax`. Silent
|
|
131
|
+
* arrays (max 0) and already-loud arrays (max above `targetMax`) are returned
|
|
132
|
+
* untouched, so the function never amplifies clipping or divides by zero.
|
|
133
|
+
*
|
|
134
|
+
* @param {number[]} peaks - Peak values, typically in the 0-1 range.
|
|
135
|
+
* @param {number} [targetMax=0.95] - Desired maximum peak after scaling.
|
|
136
|
+
* @returns {number[]} The normalized peak array (the original array when no scaling is applied).
|
|
101
137
|
* @private
|
|
102
138
|
*/
|
|
103
139
|
function normalizePeaks(peaks, targetMax = 0.95) {
|
package/src/js/bpm.js
CHANGED
|
@@ -4,9 +4,18 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Estimate the tempo (beats per minute) of an audio buffer.
|
|
8
|
+
*
|
|
9
|
+
* Analyses the first (left/mono) channel by detecting onsets, measuring the
|
|
10
|
+
* time between successive onsets, converting each interval to a tempo, and
|
|
11
|
+
* histogramming those tempos into 3-BPM buckets (60-200 BPM) to find the most
|
|
12
|
+
* common one. Octave errors are corrected by doubling very slow results and
|
|
13
|
+
* halving very fast ones when a strong half/double bucket also exists, then a
|
|
14
|
+
* fixed -1 BPM calibration offset is applied. Returns a 120 BPM fallback when
|
|
15
|
+
* too few onsets are found, and null if analysis throws.
|
|
16
|
+
*
|
|
17
|
+
* @param {AudioBuffer} buffer - Decoded audio buffer to analyse; only channel 0 is read.
|
|
18
|
+
* @returns {number|null} Detected tempo in BPM, 120 as a fallback when onsets are insufficient, or null on error.
|
|
10
19
|
*/
|
|
11
20
|
export function detectBPM(buffer) {
|
|
12
21
|
try {
|
|
@@ -51,13 +60,25 @@ export function detectBPM(buffer) {
|
|
|
51
60
|
|
|
52
61
|
return detectedBPM - 1; // Calibration offset
|
|
53
62
|
} catch (e) {
|
|
54
|
-
console.warn('BPM detection failed:', e);
|
|
63
|
+
console.warn('[WaveformPlayer] BPM detection failed:', e);
|
|
55
64
|
return null;
|
|
56
65
|
}
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
/**
|
|
60
|
-
* Detect
|
|
69
|
+
* Detect onset sample positions (transients/beats) within a channel of audio.
|
|
70
|
+
*
|
|
71
|
+
* Slides a 2048-sample window (50% overlap via a half-window hop) across the
|
|
72
|
+
* signal, computing the mean squared energy of each window. An onset is flagged
|
|
73
|
+
* when the energy rise over the previous (smoothed) energy exceeds an adaptive
|
|
74
|
+
* threshold and the window energy is above a noise floor, subject to a minimum
|
|
75
|
+
* spacing of 150 ms so a single transient is not counted twice. The running
|
|
76
|
+
* previousEnergy is exponentially smoothed (0.8 new / 0.2 old) to track the
|
|
77
|
+
* local energy envelope.
|
|
78
|
+
*
|
|
79
|
+
* @param {Float32Array} channelData - PCM samples (normalised -1..1) for a single channel.
|
|
80
|
+
* @param {number} sampleRate - Sample rate in Hz, used to derive the minimum onset spacing.
|
|
81
|
+
* @returns {number[]} Ascending sample indices at which onsets were detected.
|
|
61
82
|
* @private
|
|
62
83
|
*/
|
|
63
84
|
function detectOnsets(channelData, sampleRate) {
|