Skip to content

Commit 2a27ca4

Browse files
serpentbladeclaude
andcommitted
feat(wavesurfer): promote peaks + duration to first-class props
Surface `peaks` (pre-computed waveform, untyped) and `duration` (Number) as construction-time props so the render-without-decoding path (SSR / offline / deterministic tests) is discoverable instead of buried in the `options` bag. They override `options` ONLY when actually provided (conditional assignment), so existing options.peaks callers — incl. the VR cell — keep working. Surface now 27 props; compile ×6 / typecheck ×6 / build ×6 / surface 14/14 / props-table validated / VR 6/6 re-validated on Linux. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lxeam8c3XSttraCZPxufKM
1 parent 9bdd7dd commit 2a27ca4

17 files changed

Lines changed: 145 additions & 21 deletions

File tree

docs/components/wavesurfer.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,8 @@ There are two **two-way** model props — `currentTime` and `regions` (bind eith
219219
| Name | Type | Default | Two-way (model) | Runtime-updatable? | Description |
220220
| --- | --- | --- | :---: | :---: | --- |
221221
| `src` | `String` | `null` | || The audio URL the waveform loads. Changing it calls `load(url)`. |
222+
| `peaks` | `unknown` | `undefined` | | | Pre-computed waveform peaks (array of channel sample arrays, or a single `number[]`) — render without downloading/decoding audio; pair with `duration`. **Construction-only.** |
223+
| `duration` | `Number` | `null` | | | Audio duration in seconds — required alongside `peaks` when there's no decodable `src`. **Construction-only.** |
222224
| `height` | `Number` | `128` | || The waveform height in pixels. Reconciled via `setOptions`. |
223225
| `waveColor` | `String` | `"#8a2be2"` | || Color of the unplayed portion of the waveform. Reconciled via `setOptions`. |
224226
| `progressColor` | `String` | `"#5a189a"` | || Color of the played (progress) portion. Reconciled via `setOptions`. |
@@ -308,8 +310,8 @@ When you drive `regions` as a controlled list, include a stable `id` on each des
308310

309311
### Offline / deterministic rendering
310312

311-
To render a waveform without decoding audio (CI, tests, SSR-adjacent), pass a pre-computed peaks array and a duration through `options`:
313+
To render a waveform without decoding audio (CI, tests, SSR-adjacent), pass a pre-computed peaks array and a duration — no `src` needed:
312314

313315
```ts
314-
<Waveform :options="{ peaks: [/* -1..1 samples */], duration: 8 }" src="/audio.mp3" />
316+
<Waveform :peaks="[/* -1..1 samples */]" :duration="8" />
315317
```

packages/ui/wavesurfer/packages/angular/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ export class DemoComponent {
4141
| Name | Type | Default | Two-way (model) | Required |
4242
| --- | --- | --- | :---: | :---: |
4343
| `src` | `String` | `null` | | |
44+
| `peaks` | `unknown` | `undefined` | | |
45+
| `duration` | `Number` | `null` | | |
4446
| `height` | `Number` | `128` | | |
4547
| `waveColor` | `String` | `"#8a2be2"` | | |
4648
| `progressColor` | `String` | `"#5a189a"` | | |

packages/ui/wavesurfer/packages/angular/src/Waveform.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,14 @@ export class Waveform {
3535
* <Waveform :src="audioUrl" r-model:currentTime="time" />
3636
*/
3737
src = input<(string) | null>(null);
38+
/**
39+
* Pre-computed waveform peaks (an array of channel sample arrays, or a single `number[]`). Renders the waveform without downloading or decoding audio — pair with `duration`. Construction-only.
40+
*/
41+
peaks = input<unknown>(undefined);
42+
/**
43+
* The audio duration in seconds. Required alongside `peaks` when rendering without a decodable `src` (the timeline/ruler and region positions are derived from it). Construction-only.
44+
*/
45+
duration = input<(number) | null>(null);
3846
/**
3947
* The waveform height in pixels. Reconciled at runtime via `setOptions`.
4048
*/
@@ -124,7 +132,7 @@ export class Waveform {
124132
*/
125133
regionColor = input<(string) | null>(null);
126134
/**
127-
* Raw wavesurfer `WaveSurferOptions` passthrough — spread into `WaveSurfer.create()` before the curated keys (explicit props win). Use it for any v7 option not surfaced as a first-class prop (`peaks`, `duration`, `sampleRate`, `mediaControls`, `splitChannels`, …).
135+
* Raw wavesurfer `WaveSurferOptions` passthrough — spread into `WaveSurfer.create()` before the curated keys (explicit props win). Use it for any v7 option not surfaced as a first-class prop (`sampleRate`, `mediaControls`, `splitChannels`, `barHeight`, …).
128136
*/
129137
options = input<Record<string, any>>((() => ({}))());
130138
/**
@@ -315,6 +323,8 @@ export class Waveform {
315323
if (addedWithoutId) this.writeBackRegions();
316324
};
317325
buildWaveSurfer = () => {
326+
const __peaks = this.peaks();
327+
const __duration = this.duration();
318328
let plugins = [];
319329
plugins = [];
320330
if (this.timeline()) plugins.push(TimelinePlugin.create());
@@ -348,6 +358,10 @@ export class Waveform {
348358
dragToSeek: !this.disableDragToSeek(),
349359
plugins: plugins
350360
};
361+
// peaks/duration override the `options` bag ONLY when actually provided —
362+
// assigning `undefined` unconditionally would clobber a caller's options.peaks.
363+
if (__peaks != null) cfg.peaks = __peaks;
364+
if (__duration != null) cfg.duration = __duration;
351365
this.ws = WaveSurfer.create(cfg);
352366

353367
// ── engine events → emits + the two-way currentTime writeback ──────────────

packages/ui/wavesurfer/packages/lit/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ el.addEventListener('ready', (e) => console.log('duration', e.detail));
2929
| Name | Type | Default | Two-way (model) | Required |
3030
| --- | --- | --- | :---: | :---: |
3131
| `src` | `String` | `null` | | |
32+
| `peaks` | `unknown` | `undefined` | | |
33+
| `duration` | `Number` | `null` | | |
3234
| `height` | `Number` | `128` | | |
3335
| `waveColor` | `String` | `"#8a2be2"` | | |
3436
| `progressColor` | `String` | `"#5a189a"` | | |

packages/ui/wavesurfer/packages/lit/src/Waveform.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ export default class Waveform extends SignalWatcher(LitElement) {
3030
* <Waveform :src="audioUrl" r-model:currentTime="time" />
3131
*/
3232
@property({ type: String, reflect: true }) src: string | null = null;
33+
/**
34+
* Pre-computed waveform peaks (an array of channel sample arrays, or a single `number[]`). Renders the waveform without downloading or decoding audio — pair with `duration`. Construction-only.
35+
*/
36+
@property({ type: Object }) peaks: unknown = undefined;
37+
/**
38+
* The audio duration in seconds. Required alongside `peaks` when rendering without a decodable `src` (the timeline/ruler and region positions are derived from it). Construction-only.
39+
*/
40+
@property({ type: Number, reflect: true }) duration: number | null = null;
3341
/**
3442
* The waveform height in pixels. Reconciled at runtime via `setOptions`.
3543
*/
@@ -120,7 +128,7 @@ export default class Waveform extends SignalWatcher(LitElement) {
120128
*/
121129
@property({ type: String, reflect: true }) regionColor: string | null = null;
122130
/**
123-
* Raw wavesurfer `WaveSurferOptions` passthrough — spread into `WaveSurfer.create()` before the curated keys (explicit props win). Use it for any v7 option not surfaced as a first-class prop (`peaks`, `duration`, `sampleRate`, `mediaControls`, `splitChannels`, …).
131+
* Raw wavesurfer `WaveSurferOptions` passthrough — spread into `WaveSurfer.create()` before the curated keys (explicit props win). Use it for any v7 option not surfaced as a first-class prop (`sampleRate`, `mediaControls`, `splitChannels`, `barHeight`, …).
124132
*/
125133
@property({ type: Object }) options: any = {};
126134
/**
@@ -355,6 +363,10 @@ private __rozieFirstUpdateDone = false;
355363
dragToSeek: !this.disableDragToSeek,
356364
plugins: plugins
357365
};
366+
// peaks/duration override the `options` bag ONLY when actually provided —
367+
// assigning `undefined` unconditionally would clobber a caller's options.peaks.
368+
if (this.peaks != null) cfg.peaks = this.peaks;
369+
if (this.duration != null) cfg.duration = this.duration;
358370
this.ws = WaveSurfer.create(cfg);
359371

360372
// ── engine events → emits + the two-way currentTime writeback ──────────────
@@ -569,7 +581,7 @@ private __rozieFirstUpdateDone = false;
569581
* (explicit `attribute:`) AND lowercased property name (Lit's default).
570582
*/
571583
private get $attrs(): Record<string, string> {
572-
const __skip = new Set<string>(['src', 'height', 'wave-color', 'wavecolor', 'progress-color', 'progresscolor', 'cursor-color', 'cursorcolor', 'cursor-width', 'cursorwidth', 'bar-width', 'barwidth', 'bar-gap', 'bargap', 'bar-radius', 'barradius', 'min-px-per-sec', 'minpxpersec', 'volume', 'playback-rate', 'playbackrate', 'autoplay', 'normalize-amplitude', 'normalizeamplitude', 'hide-scrollbar', 'hidescrollbar', 'disable-interaction', 'disableinteraction', 'disable-drag-to-seek', 'disabledragtoseek', 'timeline', 'hover', 'hover-color', 'hovercolor', 'regions', 'drag-to-create-regions', 'dragtocreateregions', 'region-color', 'regioncolor', 'options', 'current-time', 'currenttime']);
584+
const __skip = new Set<string>(['src', 'peaks', 'duration', 'height', 'wave-color', 'wavecolor', 'progress-color', 'progresscolor', 'cursor-color', 'cursorcolor', 'cursor-width', 'cursorwidth', 'bar-width', 'barwidth', 'bar-gap', 'bargap', 'bar-radius', 'barradius', 'min-px-per-sec', 'minpxpersec', 'volume', 'playback-rate', 'playbackrate', 'autoplay', 'normalize-amplitude', 'normalizeamplitude', 'hide-scrollbar', 'hidescrollbar', 'disable-interaction', 'disableinteraction', 'disable-drag-to-seek', 'disabledragtoseek', 'timeline', 'hover', 'hover-color', 'hovercolor', 'regions', 'drag-to-create-regions', 'dragtocreateregions', 'region-color', 'regioncolor', 'options', 'current-time', 'currenttime']);
573585
const out: Record<string, string> = {};
574586
for (const a of Array.from(this.attributes)) {
575587
if (__skip.has(a.name)) continue;

packages/ui/wavesurfer/packages/react/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export function Demo() {
3636
| Name | Type | Default | Two-way (model) | Required |
3737
| --- | --- | --- | :---: | :---: |
3838
| `src` | `String` | `null` | | |
39+
| `peaks` | `unknown` | `undefined` | | |
40+
| `duration` | `Number` | `null` | | |
3941
| `height` | `Number` | `128` | | |
4042
| `waveColor` | `String` | `"#8a2be2"` | | |
4143
| `progressColor` | `String` | `"#5a189a"` | | |

packages/ui/wavesurfer/packages/react/src/Waveform.d.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ export interface WaveformProps {
99
* <Waveform :src="audioUrl" r-model:currentTime="time" />
1010
*/
1111
src?: (string) | null;
12+
/**
13+
* Pre-computed waveform peaks (an array of channel sample arrays, or a single `number[]`). Renders the waveform without downloading or decoding audio — pair with `duration`. Construction-only.
14+
*/
15+
peaks?: unknown;
16+
/**
17+
* The audio duration in seconds. Required alongside `peaks` when rendering without a decodable `src` (the timeline/ruler and region positions are derived from it). Construction-only.
18+
*/
19+
duration?: (number) | null;
1220
/**
1321
* The waveform height in pixels. Reconciled at runtime via `setOptions`.
1422
*/
@@ -100,7 +108,7 @@ export interface WaveformProps {
100108
*/
101109
regionColor?: (string) | null;
102110
/**
103-
* Raw wavesurfer `WaveSurferOptions` passthrough — spread into `WaveSurfer.create()` before the curated keys (explicit props win). Use it for any v7 option not surfaced as a first-class prop (`peaks`, `duration`, `sampleRate`, `mediaControls`, `splitChannels`, …).
111+
* Raw wavesurfer `WaveSurferOptions` passthrough — spread into `WaveSurfer.create()` before the curated keys (explicit props win). Use it for any v7 option not surfaced as a first-class prop (`sampleRate`, `mediaControls`, `splitChannels`, `barHeight`, …).
104112
*/
105113
options?: Record<string, unknown>;
106114
/**

packages/ui/wavesurfer/packages/react/src/Waveform.tsx

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ interface WaveformProps {
2222
* <Waveform :src="audioUrl" r-model:currentTime="time" />
2323
*/
2424
src?: (string) | null;
25+
/**
26+
* Pre-computed waveform peaks (an array of channel sample arrays, or a single `number[]`). Renders the waveform without downloading or decoding audio — pair with `duration`. Construction-only.
27+
*/
28+
peaks?: unknown;
29+
/**
30+
* The audio duration in seconds. Required alongside `peaks` when rendering without a decodable `src` (the timeline/ruler and region positions are derived from it). Construction-only.
31+
*/
32+
duration?: (number) | null;
2533
/**
2634
* The waveform height in pixels. Reconciled at runtime via `setOptions`.
2735
*/
@@ -113,7 +121,7 @@ interface WaveformProps {
113121
*/
114122
regionColor?: (string) | null;
115123
/**
116-
* Raw wavesurfer `WaveSurferOptions` passthrough — spread into `WaveSurfer.create()` before the curated keys (explicit props win). Use it for any v7 option not surfaced as a first-class prop (`peaks`, `duration`, `sampleRate`, `mediaControls`, `splitChannels`, …).
124+
* Raw wavesurfer `WaveSurferOptions` passthrough — spread into `WaveSurfer.create()` before the curated keys (explicit props win). Use it for any v7 option not surfaced as a first-class prop (`sampleRate`, `mediaControls`, `splitChannels`, `barHeight`, …).
117125
*/
118126
options?: Record<string, any>;
119127
/**
@@ -161,9 +169,11 @@ export interface WaveformHandle {
161169

162170
const Waveform = forwardRef<WaveformHandle, WaveformProps>(function Waveform(_props: WaveformProps, ref): JSX.Element {
163171
const __defaultOptions = useState(() => (() => ({}))())[0];
164-
const props: Omit<WaveformProps, 'src' | 'height' | 'waveColor' | 'progressColor' | 'cursorColor' | 'cursorWidth' | 'barWidth' | 'barGap' | 'barRadius' | 'minPxPerSec' | 'volume' | 'playbackRate' | 'autoplay' | 'normalizeAmplitude' | 'hideScrollbar' | 'disableInteraction' | 'disableDragToSeek' | 'timeline' | 'hover' | 'hoverColor' | 'dragToCreateRegions' | 'regionColor' | 'options'> & { src: (string) | null; height: number; waveColor: string; progressColor: string; cursorColor: string; cursorWidth: number; barWidth: (unknown) | null; barGap: (unknown) | null; barRadius: (unknown) | null; minPxPerSec: number; volume: number; playbackRate: number; autoplay: boolean; normalizeAmplitude: boolean; hideScrollbar: boolean; disableInteraction: boolean; disableDragToSeek: boolean; timeline: boolean; hover: boolean; hoverColor: (string) | null; dragToCreateRegions: boolean; regionColor: (string) | null; options: Record<string, any> } = {
172+
const props: Omit<WaveformProps, 'src' | 'peaks' | 'duration' | 'height' | 'waveColor' | 'progressColor' | 'cursorColor' | 'cursorWidth' | 'barWidth' | 'barGap' | 'barRadius' | 'minPxPerSec' | 'volume' | 'playbackRate' | 'autoplay' | 'normalizeAmplitude' | 'hideScrollbar' | 'disableInteraction' | 'disableDragToSeek' | 'timeline' | 'hover' | 'hoverColor' | 'dragToCreateRegions' | 'regionColor' | 'options'> & { src: (string) | null; peaks: unknown; duration: (number) | null; height: number; waveColor: string; progressColor: string; cursorColor: string; cursorWidth: number; barWidth: (unknown) | null; barGap: (unknown) | null; barRadius: (unknown) | null; minPxPerSec: number; volume: number; playbackRate: number; autoplay: boolean; normalizeAmplitude: boolean; hideScrollbar: boolean; disableInteraction: boolean; disableDragToSeek: boolean; timeline: boolean; hover: boolean; hoverColor: (string) | null; dragToCreateRegions: boolean; regionColor: (string) | null; options: Record<string, any> } = {
165173
..._props,
166174
src: _props.src ?? null,
175+
peaks: _props.peaks ?? undefined,
176+
duration: _props.duration ?? null,
167177
height: _props.height ?? 128,
168178
waveColor: _props.waveColor ?? '#8a2be2',
169179
progressColor: _props.progressColor ?? '#5a189a',
@@ -188,8 +198,8 @@ const Waveform = forwardRef<WaveformHandle, WaveformProps>(function Waveform(_pr
188198
options: _props.options ?? __defaultOptions,
189199
};
190200
const attrs: Record<string, unknown> = (() => {
191-
const { src, height, waveColor, progressColor, cursorColor, cursorWidth, barWidth, barGap, barRadius, minPxPerSec, volume, playbackRate, autoplay, normalizeAmplitude, hideScrollbar, disableInteraction, disableDragToSeek, timeline, hover, hoverColor, regions, dragToCreateRegions, regionColor, options, currentTime, defaultValue, onRegionsChange, defaultRegions, onCurrentTimeChange, defaultCurrentTime, ...rest } = _props as WaveformProps & Record<string, unknown>;
192-
void src; void height; void waveColor; void progressColor; void cursorColor; void cursorWidth; void barWidth; void barGap; void barRadius; void minPxPerSec; void volume; void playbackRate; void autoplay; void normalizeAmplitude; void hideScrollbar; void disableInteraction; void disableDragToSeek; void timeline; void hover; void hoverColor; void regions; void dragToCreateRegions; void regionColor; void options; void currentTime; void defaultValue; void onRegionsChange; void defaultRegions; void onCurrentTimeChange; void defaultCurrentTime;
201+
const { src, peaks, duration, height, waveColor, progressColor, cursorColor, cursorWidth, barWidth, barGap, barRadius, minPxPerSec, volume, playbackRate, autoplay, normalizeAmplitude, hideScrollbar, disableInteraction, disableDragToSeek, timeline, hover, hoverColor, regions, dragToCreateRegions, regionColor, options, currentTime, defaultValue, onRegionsChange, defaultRegions, onCurrentTimeChange, defaultCurrentTime, ...rest } = _props as WaveformProps & Record<string, unknown>;
202+
void src; void peaks; void duration; void height; void waveColor; void progressColor; void cursorColor; void cursorWidth; void barWidth; void barGap; void barRadius; void minPxPerSec; void volume; void playbackRate; void autoplay; void normalizeAmplitude; void hideScrollbar; void disableInteraction; void disableDragToSeek; void timeline; void hover; void hoverColor; void regions; void dragToCreateRegions; void regionColor; void options; void currentTime; void defaultValue; void onRegionsChange; void defaultRegions; void onCurrentTimeChange; void defaultCurrentTime;
193203
return rest;
194204
})();
195205
const regionsPlugin = useRef<any>(null);
@@ -324,6 +334,10 @@ const Waveform = forwardRef<WaveformHandle, WaveformProps>(function Waveform(_pr
324334
dragToSeek: !props.disableDragToSeek,
325335
plugins: plugins
326336
};
337+
// peaks/duration override the `options` bag ONLY when actually provided —
338+
// assigning `undefined` unconditionally would clobber a caller's options.peaks.
339+
if (props.peaks != null) cfg.peaks = props.peaks;
340+
if (props.duration != null) cfg.duration = props.duration;
327341
ws.current = WaveSurfer.create(cfg);
328342

329343
// ── engine events → emits + the two-way currentTime writeback ──────────────
@@ -390,7 +404,7 @@ const Waveform = forwardRef<WaveformHandle, WaveformProps>(function Waveform(_pr
390404
_rozieProp_onRegionOut && _rozieProp_onRegionOut(serializeRegion(region));
391405
});
392406
}
393-
}, [_rozieProp_onError, _rozieProp_onFinished, _rozieProp_onInteraction, _rozieProp_onLoading, _rozieProp_onPaused, _rozieProp_onPlaying, _rozieProp_onReady, _rozieProp_onRegionClicked, _rozieProp_onRegionCreated, _rozieProp_onRegionIn, _rozieProp_onRegionOut, _rozieProp_onRegionRemoved, _rozieProp_onRegionUpdated, _rozieProp_onSeeking, _rozieProp_onTimeupdate, props.autoplay, props.barGap, props.barRadius, props.barWidth, props.cursorColor, props.cursorWidth, props.disableDragToSeek, props.disableInteraction, props.dragToCreateRegions, props.height, props.hideScrollbar, props.hover, props.hoverColor, props.minPxPerSec, props.normalizeAmplitude, props.options, props.progressColor, props.regionColor, props.src, props.timeline, props.waveColor, reconcileRegions, regions, serializeRegion, setCurrentTime, writeBackRegions]);
407+
}, [_rozieProp_onError, _rozieProp_onFinished, _rozieProp_onInteraction, _rozieProp_onLoading, _rozieProp_onPaused, _rozieProp_onPlaying, _rozieProp_onReady, _rozieProp_onRegionClicked, _rozieProp_onRegionCreated, _rozieProp_onRegionIn, _rozieProp_onRegionOut, _rozieProp_onRegionRemoved, _rozieProp_onRegionUpdated, _rozieProp_onSeeking, _rozieProp_onTimeupdate, props.autoplay, props.barGap, props.barRadius, props.barWidth, props.cursorColor, props.cursorWidth, props.disableDragToSeek, props.disableInteraction, props.dragToCreateRegions, props.duration, props.height, props.hideScrollbar, props.hover, props.hoverColor, props.minPxPerSec, props.normalizeAmplitude, props.options, props.peaks, props.progressColor, props.regionColor, props.src, props.timeline, props.waveColor, reconcileRegions, regions, serializeRegion, setCurrentTime, writeBackRegions]);
394408
// ─── imperative handle (Phase 21 $expose) ────────────────────────────────────
395409
// Collision-clear across all six targets: canonical media verbs play/pause/
396410
// playPause kept (the emits were renamed playing/paused/finished to dodge ROZ121);

packages/ui/wavesurfer/packages/solid/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export function Demo() {
3636
| Name | Type | Default | Two-way (model) | Required |
3737
| --- | --- | --- | :---: | :---: |
3838
| `src` | `String` | `null` | | |
39+
| `peaks` | `unknown` | `undefined` | | |
40+
| `duration` | `Number` | `null` | | |
3941
| `height` | `Number` | `128` | | |
4042
| `waveColor` | `String` | `"#8a2be2"` | | |
4143
| `progressColor` | `String` | `"#5a189a"` | | |

0 commit comments

Comments
 (0)