Appearance
Waveform — live demo
This is the real @rozie-ui/wavesurfer-vue package running on this page (VitePress is itself a Vue app). The audio is a tone synthesized in-browser — no network needed. Press play, drag to seek, zoom, change speed. Everything below is driven by the same Waveform.rozie source that compiles to all six frameworks.
The playback position is two-way bound with v-model:currentTime — the readout updates live as it plays, and the buttons drive the imperative handle (playPause, stop, setPlaybackRate, setZoom). See the full API for the complete prop/event/handle surface.
One source, six outputs
You author the component once as a .rozie file:
html
<!--
Waveform.rozie — data-bound port of wavesurfer.js v7 (wavesurfer.js@^7).
wavesurfer.js is the de-facto vanilla-JS audio-waveform engine (2D canvas +
Web Audio). Per-framework wrappers are LOPSIDED: React has `@wavesurfer/react`;
Angular, Svelte, Solid and Lit have thin, stale, or absent wrappers. ONE Rozie
source ships six idiomatic packages — so every ecosystem gets a category-leading
waveform player for free. That market gap is exactly Rozie's write-once-ship-six
thesis.
WHY v7 (not v6): v7 is the ESM rewrite. `WaveSurfer.create({ container, url })`
is the imperative vanilla engine; each plugin is a separate entry
(`wavesurfer.js/plugins/*`) exposing a `.create(opts)` factory passed into the
`plugins: []` array at construction. Pin `wavesurfer.js@^7`.
v1 SCOPE = Core + Timeline + Hover:
- Core: waveform render + full playback (play/pause/seek/volume/rate/zoom),
the two-way `currentTime` model, the event set, and the imperative handle.
- Two STATELESS plugins (timeline ruler, hover cursor) — pure config, no
interactive writeback — prove the plugin-wiring path ×6 without region CRUD.
DEFERRED (fast-follow): interactive Regions, a #controls
scoped slot, spectrogram/minimap/envelope/record.
Usage:
<Waveform
:src="$data.audioUrl"
r-model:currentTime="$data.time" (two-way playback position, seconds)
:wave-color="'#8a2be2'"
:timeline="true"
:hover="true"
@ready="onReady"
@playing="onPlay"
/>
KEY facts that drive this port:
- The engine attaches to a container element: `WaveSurfer.create({ container })`.
We render a <div ref="container">; `$refs.container` is read ONLY inside
$onMount (ROZ123). v7 renders its canvas + an internal shadow wrapper INTO
that container — it needs NO external CSS, so the scoped .rozie <style> only
sizes the host (no consumer CSS import, unlike Cropper).
- COLLISION DISCIPLINE — the engine's playback methods (`play`/`pause`) share
names with its events (`play`/`pause` on the engine event bus). A bare
`play`/`pause` $expose verb would collide with same-named emits (ROZ121, the
Cropper crop/zoom class). Because `.play()`/`.pause()` are the canonical
media-handle API (mirroring HTMLMediaElement), we keep the VERBS and rename
the EMITS to `playing`/`paused`/`finished`. `currentTime` is the lone
model:true prop, so React auto-generates a `setCurrentTime` setter — NO
`setCurrentTime` verb is exposed (ROZ524, the Cropper setData class); the
distinct `setTime` verb (→ engine `setTime(seconds)`) is safe.
- Two-way `currentTime`: the engine `timeupdate` event carries the live
position; we echo it into $model.currentTime and emit it. The reverse $watch
is round-trip-guarded (|v − getCurrentTime()| < tolerance) so a consumer
write → setTime → timeupdate → $model → $watch loop settles instead of
oscillating (the Cropper sameData idiom, value-equality not flag-timing).
- Plugin PRESENCE is live. `timeline` / `hover` / the Regions plugin all
register/unregister on the LIVE engine (wavesurfer.js `registerPlugin`/
`unregisterPlugin`, no recreation) as their driving props toggle — no
remount. `hoverColor` / `dragToCreateRegions` / `regionColor` are read
only when their plugin is (re-)created, not live on an already-
registered instance. The `options` passthrough covers any v7
WaveSurferOptions not surfaced as a first-class prop.
-->
<rozie name="Waveform">
<props>
{
// audio source URL — bound at construction AND reconciled at runtime via load().
src: {
type: String,
default: null,
docs: {
description:
'The audio URL the waveform loads. Bound at construction and reconciled at runtime — changing it calls the engine `load(url)`.',
example: '<Waveform :src="audioUrl" r-model:currentTime="time" />',
},
},
// pre-computed waveform peaks — render WITHOUT decoding audio (SSR / offline /
// deterministic tests). Untyped (`unknown`) + `default: undefined` so it merges
// cleanly under strict typecheck (the Cropper `data` idiom). Construction-time.
peaks: {
default: undefined,
docs: {
description:
'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.',
},
},
// known audio duration in seconds — required alongside `peaks` when there is no
// `src` to decode. Construction-time.
duration: {
type: Number,
default: null,
docs: {
description:
'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.',
},
},
// waveform px height. Runtime-reconciled via setOptions().
height: {
type: Number,
default: 128,
docs: { description: 'The waveform height in pixels. Reconciled at runtime via `setOptions`.' },
},
waveColor: {
type: String,
default: '#8a2be2',
docs: { description: 'The color of the unplayed portion of the waveform. Reconciled at runtime via `setOptions`.' },
},
progressColor: {
type: String,
default: '#5a189a',
docs: { description: 'The color of the played (progress) portion of the waveform. Reconciled at runtime via `setOptions`.' },
},
cursorColor: {
type: String,
default: '#333333',
docs: { description: 'The color of the playback cursor. Reconciled at runtime via `setOptions`.' },
},
cursorWidth: {
type: Number,
default: 1,
docs: { description: 'The width of the playback cursor in pixels. Reconciled at runtime via `setOptions`.' },
},
// null → continuous (non-bar) waveform. Runtime-reconciled.
barWidth: {
default: null,
docs: { description: 'Draw the waveform as bars of this pixel width. `null` (default) renders a continuous waveform. Reconciled at runtime via `setOptions`.' },
},
barGap: {
default: null,
docs: { description: 'The pixel gap between bars (when `barWidth` is set). Reconciled at runtime via `setOptions`.' },
},
barRadius: {
default: null,
docs: { description: 'The corner radius of bars (when `barWidth` is set). Reconciled at runtime via `setOptions`.' },
},
// zoom baseline — minimum pixels per second. Runtime-reconciled via zoom().
minPxPerSec: {
type: Number,
default: 1,
docs: { description: 'The minimum pixels-per-second zoom level. Reconciled at runtime via `zoom`.' },
},
volume: {
type: Number,
default: 1,
docs: { description: 'Playback volume (`0`–`1`). Reconciled at runtime via `setVolume`.' },
},
playbackRate: {
type: Number,
default: 1,
docs: { description: 'Playback speed multiplier. Reconciled at runtime via `setPlaybackRate`.' },
},
autoplay: {
type: Boolean,
default: false,
docs: { description: 'Begin playback as soon as the audio is ready. Construction-only.' },
},
// maps to wavesurfer's `normalize` option. Named `normalizeAmplitude` (not
// `normalize`) because a `normalize` reactive property collides with the
// inherited `Node.prototype.normalize()` DOM method on the Lit custom element
// (hard TS2416 — the otp `inputMode` / pagination `totalPages` collision class).
normalizeAmplitude: {
type: Boolean,
default: false,
docs: { description: 'Normalize the waveform by its largest peak (wavesurfer\'s `normalize` option). Reconciled at runtime via `setOptions`.' },
},
hideScrollbar: {
type: Boolean,
default: false,
docs: { description: 'Hide the horizontal scrollbar when the waveform is zoomed wider than its container. Construction-only.' },
},
// engine `interact` defaults true; expose the negative opt-out. Construction-only.
disableInteraction: {
type: Boolean,
default: false,
docs: { description: 'Disable click/seek interaction with the waveform (the engine defaults to interactive). Construction-only.' },
},
// engine `dragToSeek` defaults true; expose the negative opt-out. Construction-only.
disableDragToSeek: {
type: Boolean,
default: false,
docs: { description: 'Disable drag-to-seek across the waveform (the engine defaults to drag-seekable). Construction-only.' },
},
// opt-in Timeline plugin (stateless). Live-toggleable via registerPlugin/
// unregisterPlugin on the running engine — no remount.
timeline: {
type: Boolean,
default: false,
docs: { description: 'Render a time-ruler beneath the waveform (the wavesurfer Timeline plugin). Live-toggleable — registers/unregisters on the running engine, no remount.' },
},
// opt-in Hover plugin (stateless). Live-toggleable via registerPlugin/
// unregisterPlugin on the running engine — no remount.
hover: {
type: Boolean,
default: false,
docs: { description: 'Show a hover cursor with a time label as the pointer moves over the waveform (the wavesurfer Hover plugin). Live-toggleable — registers/unregisters on the running engine, no remount.' },
},
hoverColor: {
type: String,
default: null,
docs: { description: 'The line color of the Hover plugin cursor (only applies when `hover` is enabled). Read/applied when the Hover plugin is (re-)created — not live on an already-registered instance.' },
},
// ── Regions plugin (interactive selections) ────────────────────────────────
// Providing an ARRAY (even `[]`) registers the RegionsPlugin — at
// construction if it's already an array, or LAZILY (registerPlugin on the
// live engine, no remount) the first time `regions` transitions from
// `null`/`undefined` to an array. `null`/`undefined` leaves it off. The
// lone-array shape doubles as the two-way DATA channel — see `currentTime`
// for the round-trip-guard idiom. Untyped (`unknown`) + `default: undefined`
// so it merges cleanly under the strict framework typecheck (the Cropper
// `data` idiom; React auto-generates `setRegions` — do NOT expose a
// same-named verb, ROZ524). No live-unregister path: setting `regions` back
// to `null` does not tear down the plugin once registered.
regions: {
default: undefined,
model: true,
docs: {
description:
'The interactive regions as an array of `{ id?, start, end?, content?, color?, drag?, resize? }`. Providing an array (even empty) registers the Regions plugin — at construction if it\'s already an array, or lazily the first time `regions` transitions from `null`/`undefined` to an array. Two-way (`model: true`): user create / drag / resize / remove writes the updated array back (round-trip-guarded); a consumer write reconciles the live regions (add / update / remove by `id`).',
},
},
// enable drawing new regions by dragging empty waveform space (Regions plugin
// `enableDragSelection`). Off by default (negative-space drag can fight seek).
dragToCreateRegions: {
type: Boolean,
default: false,
docs: { description: 'Allow drawing new regions by dragging over empty waveform space (Regions plugin `enableDragSelection`). Requires `regions` to be an array. Read/applied when the Regions plugin is (re-)created — not live on an already-registered instance.' },
},
// default color for drag-created regions (feeds `enableDragSelection`).
regionColor: {
type: String,
default: null,
docs: { description: 'Default fill color for drag-created regions (only applies when `dragToCreateRegions` is on). Read/applied when the Regions plugin is (re-)created — not live on an already-registered instance.' },
},
// passthrough — spread into WaveSurfer.create() BEFORE the curated keys (explicit
// props win), for any v7 WaveSurferOptions not surfaced as a first-class prop.
options: {
type: Object,
default: () => ({}),
docs: {
description:
'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`, …).',
},
},
// two-way playback position in seconds. Untyped (no `type:`) so it emits as
// `unknown` and `default: undefined` merges cleanly under the strict framework
// typecheck harness (the Cropper `data` idiom). React auto-generates
// `setCurrentTime` — do NOT expose a same-named verb (ROZ524).
currentTime: {
default: undefined,
model: true,
docs: {
description:
'The current playback position in seconds. The lone two-way `model: true` prop: playback writes the live position back on every `timeupdate` (round-trip-guarded so a programmatic write does not ping-pong), and a consumer write seeks the engine via `setTime`.',
},
},
}
</props>
<script>
// Default import is `WaveSurfer` (≠ the component name `Waveform`, so no import⇄
// component collision — Cropper had to alias; we don't). Plugin factories are
// separate v7 entry points.
import WaveSurfer from 'wavesurfer.js'
import TimelinePlugin from 'wavesurfer.js/plugins/timeline'
import HoverPlugin from 'wavesurfer.js/plugins/hover'
import RegionsPlugin from 'wavesurfer.js/plugins/regions'
// null-let so the bundled-leaf typeNeutralize pass annotates it `any`: the engine's
// strict WaveSurferOptions/return types don't match the loosely-typed .rozie props,
// and (per the engine-wrapper recipe) `ws` must be TOP-LEVEL — the Solid emitter
// splits $onMount into onMount(...) + onCleanup(...), so a mount-local `let` would
// be out of scope in teardown (TS2304).
let ws = null
// Regions plugin instance + its two guards (all top-level for the Solid teardown
// scope, same reason as `ws`). `regionsReady` gates the reconcile until the audio
// is decoded (addRegion needs a known duration). `reconciling` is the re-entrancy
// guard: while a controlled reconcile mutates the engine, the region-event
// handlers must NOT emit or write back (that would fight the incoming update) —
// only genuine USER edits (outside reconcile) drive the model + emits.
let regionsPlugin = null
let regionsReady = false
let reconciling = false
// timelinePlugin / hoverPlugin (live plugin-presence toggling) — top-level so
// the $watch(timeline)/$watch(hover) blocks below can register/unregister them
// on the running engine. wsReady tracks "the engine has decoded audio and
// fired `ready`", independent of whether a regions plugin exists — it gates
// the rare async-window lazy-registration case in the `ready` handler below.
let timelinePlugin = null
let hoverPlugin = null
let wsReady = false
// Serialize an engine Region to the plain descriptor shape the two-way `regions`
// model carries. Pure (no sigils) — safe at top level.
const serializeRegion = (r) => ({
id: r.id,
start: r.start,
end: r.end,
color: r.color,
content: r.content && r.content.textContent ? r.content.textContent : undefined,
drag: r.drag,
resize: r.resize,
})
// Value-equality guard (by id + rounded start/end) that stops the
// user-edit → writeback → $model.regions → $watch → reconcile loop from
// oscillating (the Cropper `sameData` idiom, generalized to a list).
const sameRegions = (list, engineRegions) => {
if (!Array.isArray(list) || list.length !== engineRegions.length) return false
const key = (r) => `${r.id}:${Math.round((r.start ?? 0) * 1000)}:${Math.round((r.end ?? 0) * 1000)}`
const a = list.map(key).sort()
const b = engineRegions.map(key).sort()
return a.every((k, i) => k === b[i])
}
// Push the live engine regions back into the two-way `regions` model (serialized).
// No-op while `reconciling` — a controlled update must not echo back onto itself.
const writeBackRegions = () => {
if (!regionsPlugin || reconciling) return
$model.regions = regionsPlugin.getRegions().map(serializeRegion)
}
// Reconcile the live engine regions to match a consumer-provided descriptor list:
// update-by-id, add the new, remove the missing. Guarded by `reconciling` so the
// add/remove/setOptions calls don't trigger writeBackRegions mid-flight. If any
// region was added WITHOUT a consumer id, echo the engine state (now carrying the
// assigned ids) back once so the two-way binding gains them.
const reconcileRegions = (list) => {
if (!regionsPlugin || !Array.isArray(list)) return
const current = regionsPlugin.getRegions()
if (sameRegions(list, current)) return
reconciling = true
let addedWithoutId = false
// Build the id→region map with a no-arg `new Map()` (infers Map<any, any>) — a
// `new Map(current.map(...))` over the `any`-typed engine list infers
// Map<unknown, unknown>, so `.setOptions` would fail the strict leaf typecheck.
const byId = new Map()
for (const r of current) byId.set(r.id, r)
const keep = new Set()
for (const desc of list) {
if (!desc || typeof desc.start !== 'number') continue
if (desc.id != null && byId.has(desc.id)) {
byId.get(desc.id).setOptions({
start: desc.start,
end: desc.end,
color: desc.color,
drag: desc.drag,
resize: desc.resize,
content: desc.content,
})
keep.add(desc.id)
} else {
const created = regionsPlugin.addRegion({
id: desc.id,
start: desc.start,
end: desc.end,
color: desc.color,
content: desc.content,
drag: desc.drag,
resize: desc.resize,
})
keep.add(created.id)
if (desc.id == null) addedWithoutId = true
}
}
for (const r of current) {
if (!keep.has(r.id)) r.remove()
}
reconciling = false
if (addedWithoutId) writeBackRegions()
}
// Attach the 6 region-event listeners to a live RegionsPlugin instance — shared
// by the construction-time path (buildWaveSurfer) and the lazy path
// (ensureRegionsPlugin) so both register identical behavior through one code
// path. Each writeback/emit is a no-op during a controlled reconcile (the
// `reconciling` guard) so a programmatic add/update/remove does not echo back
// or double-emit; only genuine user gestures (drag-create, drag/resize,
// delete) drive the model + emits.
const wireRegionsPluginEvents = (plugin) => {
plugin.on('region-created', (region) => {
if (reconciling) return
$emit('regionCreated', serializeRegion(region))
writeBackRegions()
})
plugin.on('region-updated', (region) => {
if (reconciling) return
$emit('regionUpdated', serializeRegion(region))
writeBackRegions()
})
plugin.on('region-removed', (region) => {
if (reconciling) return
$emit('regionRemoved', serializeRegion(region))
writeBackRegions()
})
plugin.on('region-clicked', (region) => {
$emit('regionClicked', serializeRegion(region))
})
// Playback entered/left a region — pure notifications (no writeback), so they
// fire regardless of the reconcile guard. The events for active-segment
// highlighting, transcript/karaoke sync, and loop-a-region.
plugin.on('region-in', (region) => {
$emit('regionIn', serializeRegion(region))
})
plugin.on('region-out', (region) => {
$emit('regionOut', serializeRegion(region))
})
}
// Lazily register the Regions plugin on the LIVE engine (idempotent — a no-op
// if it already exists or the engine isn't built yet). Shared by the `ready`
// handler's async-window catch-up and the $watch(regions) transition-to-array
// path, so `regions` flipping from null/undefined to an array after mount
// registers the plugin without a remount.
const ensureRegionsPlugin = () => {
if (regionsPlugin || !ws) return regionsPlugin
regionsPlugin = RegionsPlugin.create()
ws.registerPlugin(regionsPlugin)
wireRegionsPluginEvents(regionsPlugin)
if ($props.dragToCreateRegions) {
regionsPlugin.enableDragSelection({ color: $props.regionColor ?? undefined })
}
return regionsPlugin
}
// Build the engine. The whole config object is untyped (ws is `any`) so the
// constructor's options + event-callback params are unchecked against wavesurfer's
// strict types (the Cropper buildCropper idiom).
const buildWaveSurfer = () => {
let plugins = []
plugins = []
if ($props.timeline) {
timelinePlugin = TimelinePlugin.create()
plugins.push(timelinePlugin)
}
if ($props.hover) {
hoverPlugin = HoverPlugin.create({ lineColor: $props.hoverColor ?? undefined })
plugins.push(hoverPlugin)
}
// Regions plugin is registered when `regions` is an array (even empty).
regionsPlugin = null
if (Array.isArray($props.regions)) {
regionsPlugin = RegionsPlugin.create()
plugins.push(regionsPlugin)
}
let cfg = null
cfg = {
...$snapshot($props.options),
container: $refs.container,
url: $props.src ?? undefined,
height: $props.height,
waveColor: $props.waveColor,
progressColor: $props.progressColor,
cursorColor: $props.cursorColor,
cursorWidth: $props.cursorWidth,
barWidth: $props.barWidth ?? undefined,
barGap: $props.barGap ?? undefined,
barRadius: $props.barRadius ?? undefined,
minPxPerSec: $props.minPxPerSec,
autoplay: $props.autoplay,
normalize: $props.normalizeAmplitude,
hideScrollbar: $props.hideScrollbar,
interact: !$props.disableInteraction,
dragToSeek: !$props.disableDragToSeek,
plugins: plugins,
}
// peaks/duration override the `options` bag ONLY when actually provided —
// assigning `undefined` unconditionally would clobber a caller's options.peaks.
if ($props.peaks != null) cfg.peaks = $snapshot($props.peaks)
if ($props.duration != null) cfg.duration = $props.duration
ws = WaveSurfer.create(cfg)
// ── engine events → emits + the two-way currentTime writeback ──────────────
ws.on('ready', (duration) => {
wsReady = true
// Rare async-window catch-up: `regions` became an array between mount and
// `ready` firing, before `wsReady` was true, so the $watch(regions) lazy
// path below couldn't gate on it yet. ensureRegionsPlugin is idempotent.
if (Array.isArray($props.regions)) ensureRegionsPlugin()
// Regions can only be placed once the duration is known — do the initial
// reconcile + drag-selection wiring here, then open the gate for prop-driven
// reconciles. ($watch is lazy, so it never fires at mount; this is the only
// place initial regions get added.)
if (regionsPlugin) {
regionsReady = true
if ($props.dragToCreateRegions) {
regionsPlugin.enableDragSelection({ color: $props.regionColor ?? undefined })
}
reconcileRegions($snapshot($props.regions))
}
$emit('ready', duration)
})
ws.on('play', () => $emit('playing'))
ws.on('pause', () => $emit('paused'))
ws.on('finish', () => $emit('finished'))
ws.on('timeupdate', (t) => {
// Echo the live position into the two-way model, then emit. The reverse
// $watch below is value-equality-guarded, so this write does not loop.
$model.currentTime = t
$emit('timeupdate', t)
})
ws.on('seeking', (t) => $emit('seeking', t))
ws.on('interaction', (t) => $emit('interaction', t))
ws.on('loading', (percent) => $emit('loading', percent))
ws.on('error', (err) => $emit('error', err))
// ── regions plugin events ───────────────────────────────────────────────────
// Shared with the lazy ensureRegionsPlugin() path so construction-time and
// lazy registration wire identical listener behavior through one function.
if (regionsPlugin) wireRegionsPluginEvents(regionsPlugin)
}
$onMount(() => {
// $refs read ONLY here (ROZ123). The container is the engine's attach target.
buildWaveSurfer()
return () => {
if (ws) ws.destroy()
}
})
// ─── reconcile the runtime-reconcilable props (no rebuild) ───────────────────
$watch(() => $props.src, (v) => {
if (ws && typeof v === 'string' && v) ws.load(v)
})
$watch(() => $props.height, (v) => {
if (ws) ws.setOptions({ height: v })
})
$watch(() => $props.waveColor, (v) => {
if (ws) ws.setOptions({ waveColor: v })
})
$watch(() => $props.progressColor, (v) => {
if (ws) ws.setOptions({ progressColor: v })
})
$watch(() => $props.cursorColor, (v) => {
if (ws) ws.setOptions({ cursorColor: v })
})
$watch(() => $props.cursorWidth, (v) => {
if (ws) ws.setOptions({ cursorWidth: v })
})
$watch(() => $props.barWidth, (v) => {
if (ws) ws.setOptions({ barWidth: v ?? undefined })
})
$watch(() => $props.barGap, (v) => {
if (ws) ws.setOptions({ barGap: v ?? undefined })
})
$watch(() => $props.barRadius, (v) => {
if (ws) ws.setOptions({ barRadius: v ?? undefined })
})
$watch(() => $props.normalizeAmplitude, (v) => {
if (ws) ws.setOptions({ normalize: v })
})
$watch(() => $props.volume, (v) => {
if (ws && typeof v === 'number') ws.setVolume(v)
})
$watch(() => $props.playbackRate, (v) => {
if (ws && typeof v === 'number') ws.setPlaybackRate(v)
})
$watch(() => $props.minPxPerSec, (v) => {
if (ws && typeof v === 'number' && v > 0) ws.zoom(v)
})
$watch(() => $props.currentTime, (v) => {
// Round-trip guard: skip if the incoming value already matches the engine
// position (the timeupdate → $model → $watch echo), else seek.
if (!ws || typeof v !== 'number') return
if (Math.abs(v - ws.getCurrentTime()) < 0.05) return
ws.setTime(v)
})
// Plugin-presence toggling — timeline/hover register/unregister on the LIVE
// engine (wavesurfer.js `registerPlugin`/`unregisterPlugin`, no recreation),
// mirroring the reconcile-props watchers above.
$watch(() => $props.timeline, (v) => {
if (!ws) return
if (v && !timelinePlugin) {
timelinePlugin = TimelinePlugin.create()
ws.registerPlugin(timelinePlugin)
} else if (!v && timelinePlugin) {
ws.unregisterPlugin(timelinePlugin)
timelinePlugin = null
}
})
$watch(() => $props.hover, (v) => {
if (!ws) return
if (v && !hoverPlugin) {
hoverPlugin = HoverPlugin.create({ lineColor: $props.hoverColor ?? undefined })
ws.registerPlugin(hoverPlugin)
} else if (!v && hoverPlugin) {
ws.unregisterPlugin(hoverPlugin)
hoverPlugin = null
}
})
$watch(() => $props.regions, (list) => {
// Lazy registration: `regions` transitioned to an array after mount and the
// plugin doesn't exist yet — register it now. If the engine has already
// decoded audio (wsReady), open the reconcile gate immediately; otherwise
// `ready`'s own catch-up (above) opens it once duration is known.
if (Array.isArray(list) && !regionsPlugin && ws) {
ensureRegionsPlugin()
if (wsReady) regionsReady = true
}
// Controlled reconcile of the live regions to match the incoming list.
// Gated on `regionsReady` (duration known) and value-equality-guarded inside
// reconcileRegions so a writeback echo doesn't loop.
if (!regionsReady) return
reconcileRegions($snapshot(list))
})
// ─── imperative handle (Phase 21 $expose) ────────────────────────────────────
// Collision-clear across all six targets: canonical media verbs play/pause/
// playPause kept (the emits were renamed playing/paused/finished to dodge ROZ121);
// no setCurrentTime (React model auto-setter, ROZ524 — use setTime); no Lit
// reserved lifecycle name (update/render/firstUpdated/updated/willUpdate/requestUpdate).
function play() { if (ws) ws.play() }
function pause() { if (ws) ws.pause() }
function playPause() { if (ws) ws.playPause() }
function stop() { if (ws) ws.stop() }
function seekTo(progress) { if (ws) ws.seekTo(progress) }
function setTime(seconds) { if (ws) ws.setTime(seconds) }
function setVolume(v) { if (ws) ws.setVolume(v) }
function setPlaybackRate(rate) { if (ws) ws.setPlaybackRate(rate) }
function setZoom(pxPerSec) { if (ws) ws.zoom(pxPerSec) }
function load(url) { if (ws) ws.load(url) }
function isPlaying() { return ws ? ws.isPlaying() : false }
function getDuration() { return ws ? ws.getDuration() : 0 }
function getCurrentTime() { return ws ? ws.getCurrentTime() : 0 }
function getWaveSurfer() { return ws }
// Regions imperative surface (active only when the `regions` array registered the
// plugin). `addRegion` returns the created engine Region; NO `setRegions` verb
// (the React `regions`-model auto-setter, ROZ524 — drive the list via the two-way
// binding instead).
function addRegion(opts) { return regionsPlugin ? regionsPlugin.addRegion(opts) : null }
function clearRegions() { if (regionsPlugin) regionsPlugin.clearRegions() }
function getRegions() { return regionsPlugin ? regionsPlugin.getRegions() : [] }
$expose({
play, pause, playPause, stop,
seekTo, setTime, setVolume, setPlaybackRate, setZoom, load,
isPlaying, getDuration, getCurrentTime, getWaveSurfer,
addRegion, clearRegions, getRegions,
})
</script>
<template>
<div class="rozie-waveform" ref="container"></div>
</template>
<style>
.rozie-waveform {
width: 100%;
}
</style>
</rozie>…and Rozie compiles it to six idiomatic, framework-native components. Switch the tabs to see the actual generated output for each target (exactly what ships in @rozie-ui/wavesurfer-{react,vue,svelte,angular,solid,lit}):
tsx
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { clsx, useControllableState } from '@rozie/runtime-react';
import './Waveform.css';
// Default import is `WaveSurfer` (≠ the component name `Waveform`, so no import⇄
// component collision — Cropper had to alias; we don't). Plugin factories are
// separate v7 entry points.
import WaveSurfer from 'wavesurfer.js';
import TimelinePlugin from 'wavesurfer.js/plugins/timeline';
import HoverPlugin from 'wavesurfer.js/plugins/hover';
import RegionsPlugin from 'wavesurfer.js/plugins/regions';
// null-let so the bundled-leaf typeNeutralize pass annotates it `any`: the engine's
// strict WaveSurferOptions/return types don't match the loosely-typed .rozie props,
// and (per the engine-wrapper recipe) `ws` must be TOP-LEVEL — the Solid emitter
// splits $onMount into onMount(...) + onCleanup(...), so a mount-local `let` would
// be out of scope in teardown (TS2304).
interface WaveformProps {
/**
* The audio URL the waveform loads. Bound at construction and reconciled at runtime — changing it calls the engine `load(url)`.
* @example
* <Waveform :src="audioUrl" r-model:currentTime="time" />
*/
src?: (string) | null;
/**
* 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.
*/
peaks?: unknown;
/**
* 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.
*/
duration?: (number) | null;
/**
* The waveform height in pixels. Reconciled at runtime via `setOptions`.
*/
height?: number;
/**
* The color of the unplayed portion of the waveform. Reconciled at runtime via `setOptions`.
*/
waveColor?: string;
/**
* The color of the played (progress) portion of the waveform. Reconciled at runtime via `setOptions`.
*/
progressColor?: string;
/**
* The color of the playback cursor. Reconciled at runtime via `setOptions`.
*/
cursorColor?: string;
/**
* The width of the playback cursor in pixels. Reconciled at runtime via `setOptions`.
*/
cursorWidth?: number;
/**
* Draw the waveform as bars of this pixel width. `null` (default) renders a continuous waveform. Reconciled at runtime via `setOptions`.
*/
barWidth?: (unknown) | null;
/**
* The pixel gap between bars (when `barWidth` is set). Reconciled at runtime via `setOptions`.
*/
barGap?: (unknown) | null;
/**
* The corner radius of bars (when `barWidth` is set). Reconciled at runtime via `setOptions`.
*/
barRadius?: (unknown) | null;
/**
* The minimum pixels-per-second zoom level. Reconciled at runtime via `zoom`.
*/
minPxPerSec?: number;
/**
* Playback volume (`0`–`1`). Reconciled at runtime via `setVolume`.
*/
volume?: number;
/**
* Playback speed multiplier. Reconciled at runtime via `setPlaybackRate`.
*/
playbackRate?: number;
/**
* Begin playback as soon as the audio is ready. Construction-only.
*/
autoplay?: boolean;
/**
* Normalize the waveform by its largest peak (wavesurfer's `normalize` option). Reconciled at runtime via `setOptions`.
*/
normalizeAmplitude?: boolean;
/**
* Hide the horizontal scrollbar when the waveform is zoomed wider than its container. Construction-only.
*/
hideScrollbar?: boolean;
/**
* Disable click/seek interaction with the waveform (the engine defaults to interactive). Construction-only.
*/
disableInteraction?: boolean;
/**
* Disable drag-to-seek across the waveform (the engine defaults to drag-seekable). Construction-only.
*/
disableDragToSeek?: boolean;
/**
* Render a time-ruler beneath the waveform (the wavesurfer Timeline plugin). Live-toggleable — registers/unregisters on the running engine, no remount.
*/
timeline?: boolean;
/**
* Show a hover cursor with a time label as the pointer moves over the waveform (the wavesurfer Hover plugin). Live-toggleable — registers/unregisters on the running engine, no remount.
*/
hover?: boolean;
/**
* The line color of the Hover plugin cursor (only applies when `hover` is enabled). Read/applied when the Hover plugin is (re-)created — not live on an already-registered instance.
*/
hoverColor?: (string) | null;
/**
* The interactive regions as an array of `{ id?, start, end?, content?, color?, drag?, resize? }`. Providing an array (even empty) registers the Regions plugin — at construction if it's already an array, or lazily the first time `regions` transitions from `null`/`undefined` to an array. Two-way (`model: true`): user create / drag / resize / remove writes the updated array back (round-trip-guarded); a consumer write reconciles the live regions (add / update / remove by `id`).
*/
regions?: unknown;
defaultRegions?: unknown;
onRegionsChange?: (regions: unknown) => void;
/**
* Allow drawing new regions by dragging over empty waveform space (Regions plugin `enableDragSelection`). Requires `regions` to be an array. Read/applied when the Regions plugin is (re-)created — not live on an already-registered instance.
*/
dragToCreateRegions?: boolean;
/**
* Default fill color for drag-created regions (only applies when `dragToCreateRegions` is on). Read/applied when the Regions plugin is (re-)created — not live on an already-registered instance.
*/
regionColor?: (string) | null;
/**
* 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`, …).
*/
options?: Record<string, any>;
/**
* The current playback position in seconds. The lone two-way `model: true` prop: playback writes the live position back on every `timeupdate` (round-trip-guarded so a programmatic write does not ping-pong), and a consumer write seeks the engine via `setTime`.
*/
currentTime?: unknown;
defaultCurrentTime?: unknown;
onCurrentTimeChange?: (currentTime: unknown) => void;
onRegionCreated?: (...args: any[]) => void;
onRegionUpdated?: (...args: any[]) => void;
onRegionRemoved?: (...args: any[]) => void;
onRegionClicked?: (...args: any[]) => void;
onRegionIn?: (...args: any[]) => void;
onRegionOut?: (...args: any[]) => void;
onReady?: (...args: any[]) => void;
onPlaying?: (...args: any[]) => void;
onPaused?: (...args: any[]) => void;
onFinished?: (...args: any[]) => void;
onTimeupdate?: (...args: any[]) => void;
onSeeking?: (...args: any[]) => void;
onInteraction?: (...args: any[]) => void;
onLoading?: (...args: any[]) => void;
onError?: (...args: any[]) => void;
}
export interface WaveformHandle {
play: (...args: any[]) => any;
pause: (...args: any[]) => any;
playPause: (...args: any[]) => any;
stop: (...args: any[]) => any;
seekTo: (...args: any[]) => any;
setTime: (...args: any[]) => any;
setVolume: (...args: any[]) => any;
setPlaybackRate: (...args: any[]) => any;
setZoom: (...args: any[]) => any;
load: (...args: any[]) => any;
isPlaying: (...args: any[]) => any;
getDuration: (...args: any[]) => any;
getCurrentTime: (...args: any[]) => any;
getWaveSurfer: (...args: any[]) => any;
addRegion: (...args: any[]) => any;
clearRegions: (...args: any[]) => any;
getRegions: (...args: any[]) => any;
}
const Waveform = forwardRef<WaveformHandle, WaveformProps>(function Waveform(_props: WaveformProps, ref): JSX.Element {
const __defaultOptions = useState(() => (() => ({}))())[0];
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> } = {
..._props,
src: _props.src ?? null,
peaks: _props.peaks ?? undefined,
duration: _props.duration ?? null,
height: _props.height ?? 128,
waveColor: _props.waveColor ?? '#8a2be2',
progressColor: _props.progressColor ?? '#5a189a',
cursorColor: _props.cursorColor ?? '#333333',
cursorWidth: _props.cursorWidth ?? 1,
barWidth: _props.barWidth ?? null,
barGap: _props.barGap ?? null,
barRadius: _props.barRadius ?? null,
minPxPerSec: _props.minPxPerSec ?? 1,
volume: _props.volume ?? 1,
playbackRate: _props.playbackRate ?? 1,
autoplay: _props.autoplay ?? false,
normalizeAmplitude: _props.normalizeAmplitude ?? false,
hideScrollbar: _props.hideScrollbar ?? false,
disableInteraction: _props.disableInteraction ?? false,
disableDragToSeek: _props.disableDragToSeek ?? false,
timeline: _props.timeline ?? false,
hover: _props.hover ?? false,
hoverColor: _props.hoverColor ?? null,
dragToCreateRegions: _props.dragToCreateRegions ?? false,
regionColor: _props.regionColor ?? null,
options: _props.options ?? __defaultOptions,
};
const attrs: Record<string, unknown> = (() => {
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, onRegionCreated, onRegionUpdated, onRegionRemoved, onRegionClicked, onRegionIn, onRegionOut, onReady, onPlaying, onPaused, onFinished, onTimeupdate, onSeeking, onInteraction, onLoading, onError, ...rest } = _props as WaveformProps & Record<string, unknown>;
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; void onRegionCreated; void onRegionUpdated; void onRegionRemoved; void onRegionClicked; void onRegionIn; void onRegionOut; void onReady; void onPlaying; void onPaused; void onFinished; void onTimeupdate; void onSeeking; void onInteraction; void onLoading; void onError;
return rest;
})();
const timelinePlugin = useRef<any>(null);
const hoverPlugin = useRef<any>(null);
const regionsPlugin = useRef<any>(null);
const ws = useRef<any>(null);
const wsReady = useRef(false);
const regionsReady = useRef(false);
const reconciling = useRef(false);
const [regions, setRegions] = useControllableState({
value: props.regions,
defaultValue: props.defaultRegions ?? undefined,
onValueChange: props.onRegionsChange,
});
const [currentTime, setCurrentTime] = useControllableState({
value: props.currentTime,
defaultValue: props.defaultCurrentTime ?? undefined,
onValueChange: props.onCurrentTimeChange,
});
const container = useRef<HTMLDivElement | null>(null);
const _watch0First = useRef(true);
const _watch1First = useRef(true);
const _watch2First = useRef(true);
const _watch3First = useRef(true);
const _watch4First = useRef(true);
const _watch5First = useRef(true);
const _watch6First = useRef(true);
const _watch7First = useRef(true);
const _watch8First = useRef(true);
const _watch9First = useRef(true);
const _watch10First = useRef(true);
const _watch11First = useRef(true);
const _watch12First = useRef(true);
const _watch13First = useRef(true);
const _watch14First = useRef(true);
const _watch15First = useRef(true);
const _watch16First = useRef(true);
function serializeRegion(r: any) {
return {
id: r.id,
start: r.start,
end: r.end,
color: r.color,
content: r.content && r.content.textContent ? r.content.textContent : undefined,
drag: r.drag,
resize: r.resize
};
}
function sameRegions(list: any, engineRegions: any) {
if (!Array.isArray(list) || list.length !== engineRegions.length) return false;
const key = (r: any) => `${r.id}:${Math.round((r.start ?? 0) * 1000)}:${Math.round((r.end ?? 0) * 1000)}`;
const a = list.map(key).sort();
const b = engineRegions.map(key).sort();
return a.every((k: any, i: any) => k === b[i]);
}
function writeBackRegions() {
if (!regionsPlugin.current || reconciling.current) return;
setRegions(regionsPlugin.current.getRegions().map(serializeRegion));
}
function reconcileRegions(list: any) {
if (!regionsPlugin.current || !Array.isArray(list)) return;
const current = regionsPlugin.current.getRegions();
if (sameRegions(list, current)) return;
reconciling.current = true;
let addedWithoutId = false;
// Build the id→region map with a no-arg `new Map()` (infers Map<any, any>) — a
// `new Map(current.map(...))` over the `any`-typed engine list infers
// Map<unknown, unknown>, so `.setOptions` would fail the strict leaf typecheck.
const byId = new Map();
for (const r of current as any) byId.set(r.id, r);
const keep = new Set();
for (const desc of list as any) {
if (!desc || typeof desc.start !== 'number') continue;
if (desc.id != null && byId.has(desc.id)) {
byId.get(desc.id).setOptions({
start: desc.start,
end: desc.end,
color: desc.color,
drag: desc.drag,
resize: desc.resize,
content: desc.content
});
keep.add(desc.id);
} else {
const created = regionsPlugin.current.addRegion({
id: desc.id,
start: desc.start,
end: desc.end,
color: desc.color,
content: desc.content,
drag: desc.drag,
resize: desc.resize
});
keep.add(created.id);
if (desc.id == null) addedWithoutId = true;
}
}
for (const r of current as any) {
if (!keep.has(r.id)) r.remove();
}
reconciling.current = false;
if (addedWithoutId) writeBackRegions();
}
function wireRegionsPluginEvents(plugin: any) {
plugin.on('region-created', (region: any) => {
if (reconciling.current) return;
props.onRegionCreated && props.onRegionCreated(serializeRegion(region));
writeBackRegions();
});
plugin.on('region-updated', (region: any) => {
if (reconciling.current) return;
props.onRegionUpdated && props.onRegionUpdated(serializeRegion(region));
writeBackRegions();
});
plugin.on('region-removed', (region: any) => {
if (reconciling.current) return;
props.onRegionRemoved && props.onRegionRemoved(serializeRegion(region));
writeBackRegions();
});
plugin.on('region-clicked', (region: any) => {
props.onRegionClicked && props.onRegionClicked(serializeRegion(region));
});
// Playback entered/left a region — pure notifications (no writeback), so they
// fire regardless of the reconcile guard. The events for active-segment
// highlighting, transcript/karaoke sync, and loop-a-region.
plugin.on('region-in', (region: any) => {
props.onRegionIn && props.onRegionIn(serializeRegion(region));
});
plugin.on('region-out', (region: any) => {
props.onRegionOut && props.onRegionOut(serializeRegion(region));
});
}
function ensureRegionsPlugin() {
if (regionsPlugin.current || !ws.current) return regionsPlugin.current;
regionsPlugin.current = RegionsPlugin.create();
ws.current.registerPlugin(regionsPlugin.current);
wireRegionsPluginEvents(regionsPlugin.current);
if (props.dragToCreateRegions) {
regionsPlugin.current.enableDragSelection({
color: props.regionColor ?? undefined
});
}
return regionsPlugin.current;
}
const { onError: _rozieProp_onError, onFinished: _rozieProp_onFinished, onInteraction: _rozieProp_onInteraction, onLoading: _rozieProp_onLoading, onPaused: _rozieProp_onPaused, onPlaying: _rozieProp_onPlaying, onReady: _rozieProp_onReady, onSeeking: _rozieProp_onSeeking, onTimeupdate: _rozieProp_onTimeupdate } = props;
const buildWaveSurfer = useCallback(() => {
let plugins = [];
plugins = [];
if (props.timeline) {
timelinePlugin.current = TimelinePlugin.create();
plugins.push(timelinePlugin.current);
}
if (props.hover) {
hoverPlugin.current = HoverPlugin.create({
lineColor: props.hoverColor ?? undefined
});
plugins.push(hoverPlugin.current);
}
// Regions plugin is registered when `regions` is an array (even empty).
regionsPlugin.current = null;
if (Array.isArray(regions)) {
regionsPlugin.current = RegionsPlugin.create();
plugins.push(regionsPlugin.current);
}
let cfg: any = null;
cfg = {
...props.options,
container: container.current,
url: props.src ?? undefined,
height: props.height,
waveColor: props.waveColor,
progressColor: props.progressColor,
cursorColor: props.cursorColor,
cursorWidth: props.cursorWidth,
barWidth: props.barWidth ?? undefined,
barGap: props.barGap ?? undefined,
barRadius: props.barRadius ?? undefined,
minPxPerSec: props.minPxPerSec,
autoplay: props.autoplay,
normalize: props.normalizeAmplitude,
hideScrollbar: props.hideScrollbar,
interact: !props.disableInteraction,
dragToSeek: !props.disableDragToSeek,
plugins: plugins
};
// peaks/duration override the `options` bag ONLY when actually provided —
// assigning `undefined` unconditionally would clobber a caller's options.peaks.
if (props.peaks != null) cfg.peaks = props.peaks;
if (props.duration != null) cfg.duration = props.duration;
ws.current = WaveSurfer.create(cfg);
// ── engine events → emits + the two-way currentTime writeback ──────────────
ws.current.on('ready', (duration: any) => {
wsReady.current = true;
// Rare async-window catch-up: `regions` became an array between mount and
// `ready` firing, before `wsReady` was true, so the $watch(regions) lazy
// path below couldn't gate on it yet. ensureRegionsPlugin is idempotent.
if (Array.isArray(regions)) ensureRegionsPlugin();
// Regions can only be placed once the duration is known — do the initial
// reconcile + drag-selection wiring here, then open the gate for prop-driven
// reconciles. ($watch is lazy, so it never fires at mount; this is the only
// place initial regions get added.)
if (regionsPlugin.current) {
regionsReady.current = true;
if (props.dragToCreateRegions) {
regionsPlugin.current.enableDragSelection({
color: props.regionColor ?? undefined
});
}
reconcileRegions(regions);
}
_rozieProp_onReady && _rozieProp_onReady(duration);
});
ws.current.on('play', () => _rozieProp_onPlaying && _rozieProp_onPlaying());
ws.current.on('pause', () => _rozieProp_onPaused && _rozieProp_onPaused());
ws.current.on('finish', () => _rozieProp_onFinished && _rozieProp_onFinished());
ws.current.on('timeupdate', (t: any) => {
// Echo the live position into the two-way model, then emit. The reverse
// $watch below is value-equality-guarded, so this write does not loop.
setCurrentTime(t);
_rozieProp_onTimeupdate && _rozieProp_onTimeupdate(t);
});
ws.current.on('seeking', (t: any) => _rozieProp_onSeeking && _rozieProp_onSeeking(t));
ws.current.on('interaction', (t: any) => _rozieProp_onInteraction && _rozieProp_onInteraction(t));
ws.current.on('loading', (percent: any) => _rozieProp_onLoading && _rozieProp_onLoading(percent));
ws.current.on('error', (err: any) => _rozieProp_onError && _rozieProp_onError(err));
// ── regions plugin events ───────────────────────────────────────────────────
// Shared with the lazy ensureRegionsPlugin() path so construction-time and
// lazy registration wire identical listener behavior through one function.
if (regionsPlugin.current) wireRegionsPluginEvents(regionsPlugin.current);
}, [_rozieProp_onError, _rozieProp_onFinished, _rozieProp_onInteraction, _rozieProp_onLoading, _rozieProp_onPaused, _rozieProp_onPlaying, _rozieProp_onReady, _rozieProp_onSeeking, _rozieProp_onTimeupdate, ensureRegionsPlugin, 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, setCurrentTime, wireRegionsPluginEvents]);
// ─── imperative handle (Phase 21 $expose) ────────────────────────────────────
// Collision-clear across all six targets: canonical media verbs play/pause/
// playPause kept (the emits were renamed playing/paused/finished to dodge ROZ121);
// no setCurrentTime (React model auto-setter, ROZ524 — use setTime); no Lit
// reserved lifecycle name (update/render/firstUpdated/updated/willUpdate/requestUpdate).
function play() {
if (ws.current) ws.current.play();
}
function pause() {
if (ws.current) ws.current.pause();
}
function playPause() {
if (ws.current) ws.current.playPause();
}
function stop() {
if (ws.current) ws.current.stop();
}
function seekTo(progress: any) {
if (ws.current) ws.current.seekTo(progress);
}
function setTime(seconds: any) {
if (ws.current) ws.current.setTime(seconds);
}
function setVolume(v: any) {
if (ws.current) ws.current.setVolume(v);
}
function setPlaybackRate(rate: any) {
if (ws.current) ws.current.setPlaybackRate(rate);
}
function setZoom(pxPerSec: any) {
if (ws.current) ws.current.zoom(pxPerSec);
}
function load(url: any) {
if (ws.current) ws.current.load(url);
}
function isPlaying() {
return ws.current ? ws.current.isPlaying() : false;
}
function getDuration() {
return ws.current ? ws.current.getDuration() : 0;
}
function getCurrentTime() {
return ws.current ? ws.current.getCurrentTime() : 0;
}
function getWaveSurfer() {
return ws.current;
}
// Regions imperative surface (active only when the `regions` array registered the
// plugin). `addRegion` returns the created engine Region; NO `setRegions` verb
// (the React `regions`-model auto-setter, ROZ524 — drive the list via the two-way
// binding instead).
// Regions imperative surface (active only when the `regions` array registered the
// plugin). `addRegion` returns the created engine Region; NO `setRegions` verb
// (the React `regions`-model auto-setter, ROZ524 — drive the list via the two-way
// binding instead).
function addRegion(opts: any) {
return regionsPlugin.current ? regionsPlugin.current.addRegion(opts) : null;
}
function clearRegions() {
if (regionsPlugin.current) regionsPlugin.current.clearRegions();
}
function getRegions() {
return regionsPlugin.current ? regionsPlugin.current.getRegions() : [];
}
const _buildWaveSurferRef = useRef(buildWaveSurfer);
_buildWaveSurferRef.current = buildWaveSurfer;
useEffect(() => {
// $refs read ONLY here (ROZ123). The container is the engine's attach target.
_buildWaveSurferRef.current();
return () => {
if (ws.current) ws.current.destroy();
};
}, []);
useEffect(() => {
if (_watch0First.current) { _watch0First.current = false; return; }
const v = props.src;
if (ws.current && typeof v === 'string' && v) ws.current.load(v);
}, [props.src]);
useEffect(() => {
if (_watch1First.current) { _watch1First.current = false; return; }
const v = props.height;
if (ws.current) ws.current.setOptions({
height: v
});
}, [props.height]);
useEffect(() => {
if (_watch2First.current) { _watch2First.current = false; return; }
const v = props.waveColor;
if (ws.current) ws.current.setOptions({
waveColor: v
});
}, [props.waveColor]);
useEffect(() => {
if (_watch3First.current) { _watch3First.current = false; return; }
const v = props.progressColor;
if (ws.current) ws.current.setOptions({
progressColor: v
});
}, [props.progressColor]);
useEffect(() => {
if (_watch4First.current) { _watch4First.current = false; return; }
const v = props.cursorColor;
if (ws.current) ws.current.setOptions({
cursorColor: v
});
}, [props.cursorColor]);
useEffect(() => {
if (_watch5First.current) { _watch5First.current = false; return; }
const v = props.cursorWidth;
if (ws.current) ws.current.setOptions({
cursorWidth: v
});
}, [props.cursorWidth]);
useEffect(() => {
if (_watch6First.current) { _watch6First.current = false; return; }
const v = props.barWidth;
if (ws.current) ws.current.setOptions({
barWidth: v ?? undefined
});
}, [props.barWidth]);
useEffect(() => {
if (_watch7First.current) { _watch7First.current = false; return; }
const v = props.barGap;
if (ws.current) ws.current.setOptions({
barGap: v ?? undefined
});
}, [props.barGap]);
useEffect(() => {
if (_watch8First.current) { _watch8First.current = false; return; }
const v = props.barRadius;
if (ws.current) ws.current.setOptions({
barRadius: v ?? undefined
});
}, [props.barRadius]);
useEffect(() => {
if (_watch9First.current) { _watch9First.current = false; return; }
const v = props.normalizeAmplitude;
if (ws.current) ws.current.setOptions({
normalize: v
});
}, [props.normalizeAmplitude]);
useEffect(() => {
if (_watch10First.current) { _watch10First.current = false; return; }
const v = props.volume;
if (ws.current && typeof v === 'number') ws.current.setVolume(v);
}, [props.volume]);
useEffect(() => {
if (_watch11First.current) { _watch11First.current = false; return; }
const v = props.playbackRate;
if (ws.current && typeof v === 'number') ws.current.setPlaybackRate(v);
}, [props.playbackRate]);
useEffect(() => {
if (_watch12First.current) { _watch12First.current = false; return; }
const v = props.minPxPerSec;
if (ws.current && typeof v === 'number' && v > 0) ws.current.zoom(v);
}, [props.minPxPerSec]);
useEffect(() => {
if (_watch13First.current) { _watch13First.current = false; return; }
const v = currentTime;
// Round-trip guard: skip if the incoming value already matches the engine
// position (the timeupdate → $model → $watch echo), else seek.
if (!ws.current || typeof v !== 'number') return;
if (Math.abs(v - ws.current.getCurrentTime()) < 0.05) return;
ws.current.setTime(v);
}, [currentTime]);
useEffect(() => {
if (_watch14First.current) { _watch14First.current = false; return; }
const v = props.timeline;
if (!ws.current) return;
if (v && !timelinePlugin.current) {
timelinePlugin.current = TimelinePlugin.create();
ws.current.registerPlugin(timelinePlugin.current);
} else if (!v && timelinePlugin.current) {
ws.current.unregisterPlugin(timelinePlugin.current);
timelinePlugin.current = null;
}
}, [props.timeline]);
useEffect(() => {
if (_watch15First.current) { _watch15First.current = false; return; }
const v = props.hover;
if (!ws.current) return;
if (v && !hoverPlugin.current) {
hoverPlugin.current = HoverPlugin.create({
lineColor: props.hoverColor ?? undefined
});
ws.current.registerPlugin(hoverPlugin.current);
} else if (!v && hoverPlugin.current) {
ws.current.unregisterPlugin(hoverPlugin.current);
hoverPlugin.current = null;
}
}, [props.hover]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (_watch16First.current) { _watch16First.current = false; return; }
const list = regions;
// Lazy registration: `regions` transitioned to an array after mount and the
// plugin doesn't exist yet — register it now. If the engine has already
// decoded audio (wsReady), open the reconcile gate immediately; otherwise
// `ready`'s own catch-up (above) opens it once duration is known.
if (Array.isArray(list) && !regionsPlugin.current && ws.current) {
ensureRegionsPlugin();
if (wsReady.current) regionsReady.current = true;
}
// Controlled reconcile of the live regions to match the incoming list.
// Gated on `regionsReady` (duration known) and value-equality-guarded inside
// reconcileRegions so a writeback echo doesn't loop.
if (!regionsReady.current) return;
reconcileRegions(list);
}, [regions]); // eslint-disable-line react-hooks/exhaustive-deps
const _rozieExposeRef = useRef({ play, pause, playPause, stop, seekTo, setTime, setVolume, setPlaybackRate, setZoom, load, isPlaying, getDuration, getCurrentTime, getWaveSurfer, addRegion, clearRegions, getRegions });
_rozieExposeRef.current = { play, pause, playPause, stop, seekTo, setTime, setVolume, setPlaybackRate, setZoom, load, isPlaying, getDuration, getCurrentTime, getWaveSurfer, addRegion, clearRegions, getRegions };
useImperativeHandle(ref, () => ({ play: (...args: Parameters<typeof play>): ReturnType<typeof play> => _rozieExposeRef.current.play(...args), pause: (...args: Parameters<typeof pause>): ReturnType<typeof pause> => _rozieExposeRef.current.pause(...args), playPause: (...args: Parameters<typeof playPause>): ReturnType<typeof playPause> => _rozieExposeRef.current.playPause(...args), stop: (...args: Parameters<typeof stop>): ReturnType<typeof stop> => _rozieExposeRef.current.stop(...args), seekTo: (...args: Parameters<typeof seekTo>): ReturnType<typeof seekTo> => _rozieExposeRef.current.seekTo(...args), setTime: (...args: Parameters<typeof setTime>): ReturnType<typeof setTime> => _rozieExposeRef.current.setTime(...args), setVolume: (...args: Parameters<typeof setVolume>): ReturnType<typeof setVolume> => _rozieExposeRef.current.setVolume(...args), setPlaybackRate: (...args: Parameters<typeof setPlaybackRate>): ReturnType<typeof setPlaybackRate> => _rozieExposeRef.current.setPlaybackRate(...args), setZoom: (...args: Parameters<typeof setZoom>): ReturnType<typeof setZoom> => _rozieExposeRef.current.setZoom(...args), load: (...args: Parameters<typeof load>): ReturnType<typeof load> => _rozieExposeRef.current.load(...args), isPlaying: (...args: Parameters<typeof isPlaying>): ReturnType<typeof isPlaying> => _rozieExposeRef.current.isPlaying(...args), getDuration: (...args: Parameters<typeof getDuration>): ReturnType<typeof getDuration> => _rozieExposeRef.current.getDuration(...args), getCurrentTime: (...args: Parameters<typeof getCurrentTime>): ReturnType<typeof getCurrentTime> => _rozieExposeRef.current.getCurrentTime(...args), getWaveSurfer: (...args: Parameters<typeof getWaveSurfer>): ReturnType<typeof getWaveSurfer> => _rozieExposeRef.current.getWaveSurfer(...args), addRegion: (...args: Parameters<typeof addRegion>): ReturnType<typeof addRegion> => _rozieExposeRef.current.addRegion(...args), clearRegions: (...args: Parameters<typeof clearRegions>): ReturnType<typeof clearRegions> => _rozieExposeRef.current.clearRegions(...args), getRegions: (...args: Parameters<typeof getRegions>): ReturnType<typeof getRegions> => _rozieExposeRef.current.getRegions(...args) }), []);
return (
<>
<div ref={container} {...attrs} className={clsx("rozie-waveform", (attrs.className as string | undefined))} data-rozie-s-0b6fbb3a="" />
</>
);
});
export default Waveform;vue
<template>
<div class="rozie-waveform" ref="containerRef" v-bind="$attrs"></div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
const props = withDefaults(
defineProps<{
/**
* The audio URL the waveform loads. Bound at construction and reconciled at runtime — changing it calls the engine `load(url)`.
* @example
* <Waveform :src="audioUrl" r-model:currentTime="time" />
*/
src?: string | null;
/**
* 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.
*/
peaks?: unknown;
/**
* 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.
*/
duration?: number | null;
/**
* The waveform height in pixels. Reconciled at runtime via `setOptions`.
*/
height?: number;
/**
* The color of the unplayed portion of the waveform. Reconciled at runtime via `setOptions`.
*/
waveColor?: string;
/**
* The color of the played (progress) portion of the waveform. Reconciled at runtime via `setOptions`.
*/
progressColor?: string;
/**
* The color of the playback cursor. Reconciled at runtime via `setOptions`.
*/
cursorColor?: string;
/**
* The width of the playback cursor in pixels. Reconciled at runtime via `setOptions`.
*/
cursorWidth?: number;
/**
* Draw the waveform as bars of this pixel width. `null` (default) renders a continuous waveform. Reconciled at runtime via `setOptions`.
*/
barWidth?: Record<string, any> | null;
/**
* The pixel gap between bars (when `barWidth` is set). Reconciled at runtime via `setOptions`.
*/
barGap?: Record<string, any> | null;
/**
* The corner radius of bars (when `barWidth` is set). Reconciled at runtime via `setOptions`.
*/
barRadius?: Record<string, any> | null;
/**
* The minimum pixels-per-second zoom level. Reconciled at runtime via `zoom`.
*/
minPxPerSec?: number;
/**
* Playback volume (`0`–`1`). Reconciled at runtime via `setVolume`.
*/
volume?: number;
/**
* Playback speed multiplier. Reconciled at runtime via `setPlaybackRate`.
*/
playbackRate?: number;
/**
* Begin playback as soon as the audio is ready. Construction-only.
*/
autoplay?: boolean;
/**
* Normalize the waveform by its largest peak (wavesurfer's `normalize` option). Reconciled at runtime via `setOptions`.
*/
normalizeAmplitude?: boolean;
/**
* Hide the horizontal scrollbar when the waveform is zoomed wider than its container. Construction-only.
*/
hideScrollbar?: boolean;
/**
* Disable click/seek interaction with the waveform (the engine defaults to interactive). Construction-only.
*/
disableInteraction?: boolean;
/**
* Disable drag-to-seek across the waveform (the engine defaults to drag-seekable). Construction-only.
*/
disableDragToSeek?: boolean;
/**
* Render a time-ruler beneath the waveform (the wavesurfer Timeline plugin). Live-toggleable — registers/unregisters on the running engine, no remount.
*/
timeline?: boolean;
/**
* Show a hover cursor with a time label as the pointer moves over the waveform (the wavesurfer Hover plugin). Live-toggleable — registers/unregisters on the running engine, no remount.
*/
hover?: boolean;
/**
* The line color of the Hover plugin cursor (only applies when `hover` is enabled). Read/applied when the Hover plugin is (re-)created — not live on an already-registered instance.
*/
hoverColor?: string | null;
/**
* Allow drawing new regions by dragging over empty waveform space (Regions plugin `enableDragSelection`). Requires `regions` to be an array. Read/applied when the Regions plugin is (re-)created — not live on an already-registered instance.
*/
dragToCreateRegions?: boolean;
/**
* Default fill color for drag-created regions (only applies when `dragToCreateRegions` is on). Read/applied when the Regions plugin is (re-)created — not live on an already-registered instance.
*/
regionColor?: string | null;
/**
* 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`, …).
*/
options?: Record<string, any>;
}>(),
{ src: null, peaks: undefined, duration: null, height: 128, waveColor: '#8a2be2', progressColor: '#5a189a', cursorColor: '#333333', cursorWidth: 1, barWidth: null, barGap: null, barRadius: null, minPxPerSec: 1, volume: 1, playbackRate: 1, autoplay: false, normalizeAmplitude: false, hideScrollbar: false, disableInteraction: false, disableDragToSeek: false, timeline: false, hover: false, hoverColor: null, dragToCreateRegions: false, regionColor: null, options: () => ({}) }
);
/**
* The interactive regions as an array of `{ id?, start, end?, content?, color?, drag?, resize? }`. Providing an array (even empty) registers the Regions plugin — at construction if it's already an array, or lazily the first time `regions` transitions from `null`/`undefined` to an array. Two-way (`model: true`): user create / drag / resize / remove writes the updated array back (round-trip-guarded); a consumer write reconciles the live regions (add / update / remove by `id`).
*/
const regions = defineModel<unknown>('regions', { default: undefined });
/**
* The current playback position in seconds. The lone two-way `model: true` prop: playback writes the live position back on every `timeupdate` (round-trip-guarded so a programmatic write does not ping-pong), and a consumer write seeks the engine via `setTime`.
*/
const currentTime = defineModel<unknown>('currentTime', { default: undefined });
const emit = defineEmits<{
regionCreated: [...args: any[]];
regionUpdated: [...args: any[]];
regionRemoved: [...args: any[]];
regionClicked: [...args: any[]];
regionIn: [...args: any[]];
regionOut: [...args: any[]];
ready: [...args: any[]];
playing: [...args: any[]];
paused: [...args: any[]];
finished: [...args: any[]];
timeupdate: [...args: any[]];
seeking: [...args: any[]];
interaction: [...args: any[]];
loading: [...args: any[]];
error: [...args: any[]];
}>();
const containerRef = ref<HTMLElement>();
// Default import is `WaveSurfer` (≠ the component name `Waveform`, so no import⇄
// component collision — Cropper had to alias; we don't). Plugin factories are
// separate v7 entry points.
import WaveSurfer from 'wavesurfer.js';
import TimelinePlugin from 'wavesurfer.js/plugins/timeline';
import HoverPlugin from 'wavesurfer.js/plugins/hover';
import RegionsPlugin from 'wavesurfer.js/plugins/regions';
// null-let so the bundled-leaf typeNeutralize pass annotates it `any`: the engine's
// strict WaveSurferOptions/return types don't match the loosely-typed .rozie props,
// and (per the engine-wrapper recipe) `ws` must be TOP-LEVEL — the Solid emitter
// splits $onMount into onMount(...) + onCleanup(...), so a mount-local `let` would
// be out of scope in teardown (TS2304).
let ws: any = null;
// Regions plugin instance + its two guards (all top-level for the Solid teardown
// scope, same reason as `ws`). `regionsReady` gates the reconcile until the audio
// is decoded (addRegion needs a known duration). `reconciling` is the re-entrancy
// guard: while a controlled reconcile mutates the engine, the region-event
// handlers must NOT emit or write back (that would fight the incoming update) —
// only genuine USER edits (outside reconcile) drive the model + emits.
let regionsPlugin: any = null;
let regionsReady = false;
let reconciling = false;
// timelinePlugin / hoverPlugin (live plugin-presence toggling) — top-level so
// the $watch(timeline)/$watch(hover) blocks below can register/unregister them
// on the running engine. wsReady tracks "the engine has decoded audio and
// fired `ready`", independent of whether a regions plugin exists — it gates
// the rare async-window lazy-registration case in the `ready` handler below.
let timelinePlugin: any = null;
let hoverPlugin: any = null;
let wsReady = false;
// Serialize an engine Region to the plain descriptor shape the two-way `regions`
// model carries. Pure (no sigils) — safe at top level.
const serializeRegion = (r: any) => ({
id: r.id,
start: r.start,
end: r.end,
color: r.color,
content: r.content && r.content.textContent ? r.content.textContent : undefined,
drag: r.drag,
resize: r.resize
});
// Value-equality guard (by id + rounded start/end) that stops the
// user-edit → writeback → $model.regions → $watch → reconcile loop from
// oscillating (the Cropper `sameData` idiom, generalized to a list).
const sameRegions = (list: any, engineRegions: any) => {
if (!Array.isArray(list) || list.length !== engineRegions.length) return false;
const key = (r: any) => `${r.id}:${Math.round((r.start ?? 0) * 1000)}:${Math.round((r.end ?? 0) * 1000)}`;
const a = list.map(key).sort();
const b = engineRegions.map(key).sort();
return a.every((k: any, i: any) => k === b[i]);
};
// Push the live engine regions back into the two-way `regions` model (serialized).
// No-op while `reconciling` — a controlled update must not echo back onto itself.
const writeBackRegions = () => {
if (!regionsPlugin || reconciling) return;
regions.value = regionsPlugin.getRegions().map(serializeRegion);
};
// Reconcile the live engine regions to match a consumer-provided descriptor list:
// update-by-id, add the new, remove the missing. Guarded by `reconciling` so the
// add/remove/setOptions calls don't trigger writeBackRegions mid-flight. If any
// region was added WITHOUT a consumer id, echo the engine state (now carrying the
// assigned ids) back once so the two-way binding gains them.
const reconcileRegions = (list: any) => {
if (!regionsPlugin || !Array.isArray(list)) return;
const current = regionsPlugin.getRegions();
if (sameRegions(list, current)) return;
reconciling = true;
let addedWithoutId = false;
// Build the id→region map with a no-arg `new Map()` (infers Map<any, any>) — a
// `new Map(current.map(...))` over the `any`-typed engine list infers
// Map<unknown, unknown>, so `.setOptions` would fail the strict leaf typecheck.
const byId = new Map();
for (const r of current as any) byId.set(r.id, r);
const keep = new Set();
for (const desc of list as any) {
if (!desc || typeof desc.start !== 'number') continue;
if (desc.id != null && byId.has(desc.id)) {
byId.get(desc.id).setOptions({
start: desc.start,
end: desc.end,
color: desc.color,
drag: desc.drag,
resize: desc.resize,
content: desc.content
});
keep.add(desc.id);
} else {
const created = regionsPlugin.addRegion({
id: desc.id,
start: desc.start,
end: desc.end,
color: desc.color,
content: desc.content,
drag: desc.drag,
resize: desc.resize
});
keep.add(created.id);
if (desc.id == null) addedWithoutId = true;
}
}
for (const r of current as any) {
if (!keep.has(r.id)) r.remove();
}
reconciling = false;
if (addedWithoutId) writeBackRegions();
};
// Attach the 6 region-event listeners to a live RegionsPlugin instance — shared
// by the construction-time path (buildWaveSurfer) and the lazy path
// (ensureRegionsPlugin) so both register identical behavior through one code
// path. Each writeback/emit is a no-op during a controlled reconcile (the
// `reconciling` guard) so a programmatic add/update/remove does not echo back
// or double-emit; only genuine user gestures (drag-create, drag/resize,
// delete) drive the model + emits.
const wireRegionsPluginEvents = (plugin: any) => {
plugin.on('region-created', (region: any) => {
if (reconciling) return;
emit('regionCreated', serializeRegion(region));
writeBackRegions();
});
plugin.on('region-updated', (region: any) => {
if (reconciling) return;
emit('regionUpdated', serializeRegion(region));
writeBackRegions();
});
plugin.on('region-removed', (region: any) => {
if (reconciling) return;
emit('regionRemoved', serializeRegion(region));
writeBackRegions();
});
plugin.on('region-clicked', (region: any) => {
emit('regionClicked', serializeRegion(region));
});
// Playback entered/left a region — pure notifications (no writeback), so they
// fire regardless of the reconcile guard. The events for active-segment
// highlighting, transcript/karaoke sync, and loop-a-region.
plugin.on('region-in', (region: any) => {
emit('regionIn', serializeRegion(region));
});
plugin.on('region-out', (region: any) => {
emit('regionOut', serializeRegion(region));
});
};
// Lazily register the Regions plugin on the LIVE engine (idempotent — a no-op
// if it already exists or the engine isn't built yet). Shared by the `ready`
// handler's async-window catch-up and the $watch(regions) transition-to-array
// path, so `regions` flipping from null/undefined to an array after mount
// registers the plugin without a remount.
const ensureRegionsPlugin = () => {
if (regionsPlugin || !ws) return regionsPlugin;
regionsPlugin = RegionsPlugin.create();
ws.registerPlugin(regionsPlugin);
wireRegionsPluginEvents(regionsPlugin);
if (props.dragToCreateRegions) {
regionsPlugin.enableDragSelection({
color: props.regionColor ?? undefined
});
}
return regionsPlugin;
};
// Build the engine. The whole config object is untyped (ws is `any`) so the
// constructor's options + event-callback params are unchecked against wavesurfer's
// strict types (the Cropper buildCropper idiom).
const buildWaveSurfer = () => {
let plugins = [];
plugins = [];
if (props.timeline) {
timelinePlugin = TimelinePlugin.create();
plugins.push(timelinePlugin);
}
if (props.hover) {
hoverPlugin = HoverPlugin.create({
lineColor: props.hoverColor ?? undefined
});
plugins.push(hoverPlugin);
}
// Regions plugin is registered when `regions` is an array (even empty).
regionsPlugin = null;
if (Array.isArray(regions.value)) {
regionsPlugin = RegionsPlugin.create();
plugins.push(regionsPlugin);
}
let cfg: any = null;
cfg = {
...props.options,
container: containerRef.value,
url: props.src ?? undefined,
height: props.height,
waveColor: props.waveColor,
progressColor: props.progressColor,
cursorColor: props.cursorColor,
cursorWidth: props.cursorWidth,
barWidth: props.barWidth ?? undefined,
barGap: props.barGap ?? undefined,
barRadius: props.barRadius ?? undefined,
minPxPerSec: props.minPxPerSec,
autoplay: props.autoplay,
normalize: props.normalizeAmplitude,
hideScrollbar: props.hideScrollbar,
interact: !props.disableInteraction,
dragToSeek: !props.disableDragToSeek,
plugins: plugins
};
// peaks/duration override the `options` bag ONLY when actually provided —
// assigning `undefined` unconditionally would clobber a caller's options.peaks.
if (props.peaks != null) cfg.peaks = props.peaks;
if (props.duration != null) cfg.duration = props.duration;
ws = WaveSurfer.create(cfg);
// ── engine events → emits + the two-way currentTime writeback ──────────────
ws.on('ready', (duration: any) => {
wsReady = true;
// Rare async-window catch-up: `regions` became an array between mount and
// `ready` firing, before `wsReady` was true, so the $watch(regions) lazy
// path below couldn't gate on it yet. ensureRegionsPlugin is idempotent.
if (Array.isArray(regions.value)) ensureRegionsPlugin();
// Regions can only be placed once the duration is known — do the initial
// reconcile + drag-selection wiring here, then open the gate for prop-driven
// reconciles. ($watch is lazy, so it never fires at mount; this is the only
// place initial regions get added.)
if (regionsPlugin) {
regionsReady = true;
if (props.dragToCreateRegions) {
regionsPlugin.enableDragSelection({
color: props.regionColor ?? undefined
});
}
reconcileRegions(regions.value);
}
emit('ready', duration);
});
ws.on('play', () => emit('playing'));
ws.on('pause', () => emit('paused'));
ws.on('finish', () => emit('finished'));
ws.on('timeupdate', (t: any) => {
// Echo the live position into the two-way model, then emit. The reverse
// $watch below is value-equality-guarded, so this write does not loop.
currentTime.value = t;
emit('timeupdate', t);
});
ws.on('seeking', (t: any) => emit('seeking', t));
ws.on('interaction', (t: any) => emit('interaction', t));
ws.on('loading', (percent: any) => emit('loading', percent));
ws.on('error', (err: any) => emit('error', err));
// ── regions plugin events ───────────────────────────────────────────────────
// Shared with the lazy ensureRegionsPlugin() path so construction-time and
// lazy registration wire identical listener behavior through one function.
if (regionsPlugin) wireRegionsPluginEvents(regionsPlugin);
};
// ─── imperative handle (Phase 21 $expose) ────────────────────────────────────
// Collision-clear across all six targets: canonical media verbs play/pause/
// playPause kept (the emits were renamed playing/paused/finished to dodge ROZ121);
// no setCurrentTime (React model auto-setter, ROZ524 — use setTime); no Lit
// reserved lifecycle name (update/render/firstUpdated/updated/willUpdate/requestUpdate).
function play() {
if (ws) ws.play();
}
function pause() {
if (ws) ws.pause();
}
function playPause() {
if (ws) ws.playPause();
}
function stop() {
if (ws) ws.stop();
}
function seekTo(progress: any) {
if (ws) ws.seekTo(progress);
}
function setTime(seconds: any) {
if (ws) ws.setTime(seconds);
}
function setVolume(v: any) {
if (ws) ws.setVolume(v);
}
function setPlaybackRate(rate: any) {
if (ws) ws.setPlaybackRate(rate);
}
function setZoom(pxPerSec: any) {
if (ws) ws.zoom(pxPerSec);
}
function load(url: any) {
if (ws) ws.load(url);
}
function isPlaying() {
return ws ? ws.isPlaying() : false;
}
function getDuration() {
return ws ? ws.getDuration() : 0;
}
function getCurrentTime() {
return ws ? ws.getCurrentTime() : 0;
}
function getWaveSurfer() {
return ws;
}
// Regions imperative surface (active only when the `regions` array registered the
// plugin). `addRegion` returns the created engine Region; NO `setRegions` verb
// (the React `regions`-model auto-setter, ROZ524 — drive the list via the two-way
// binding instead).
function addRegion(opts: any) {
return regionsPlugin ? regionsPlugin.addRegion(opts) : null;
}
function clearRegions() {
if (regionsPlugin) regionsPlugin.clearRegions();
}
function getRegions() {
return regionsPlugin ? regionsPlugin.getRegions() : [];
}
let _cleanup_0: (() => void) | undefined;
onMounted(() => {
// $refs read ONLY here (ROZ123). The container is the engine's attach target.
buildWaveSurfer();
_cleanup_0 = () => {
if (ws) ws.destroy();
};
});
onBeforeUnmount(() => { _cleanup_0?.(); });
watch(() => props.src, (v: any) => {
if (ws && typeof v === 'string' && v) ws.load(v);
});
watch(() => props.height, (v: any) => {
if (ws) ws.setOptions({
height: v
});
});
watch(() => props.waveColor, (v: any) => {
if (ws) ws.setOptions({
waveColor: v
});
});
watch(() => props.progressColor, (v: any) => {
if (ws) ws.setOptions({
progressColor: v
});
});
watch(() => props.cursorColor, (v: any) => {
if (ws) ws.setOptions({
cursorColor: v
});
});
watch(() => props.cursorWidth, (v: any) => {
if (ws) ws.setOptions({
cursorWidth: v
});
});
watch(() => props.barWidth, (v: any) => {
if (ws) ws.setOptions({
barWidth: v ?? undefined
});
});
watch(() => props.barGap, (v: any) => {
if (ws) ws.setOptions({
barGap: v ?? undefined
});
});
watch(() => props.barRadius, (v: any) => {
if (ws) ws.setOptions({
barRadius: v ?? undefined
});
});
watch(() => props.normalizeAmplitude, (v: any) => {
if (ws) ws.setOptions({
normalize: v
});
});
watch(() => props.volume, (v: any) => {
if (ws && typeof v === 'number') ws.setVolume(v);
});
watch(() => props.playbackRate, (v: any) => {
if (ws && typeof v === 'number') ws.setPlaybackRate(v);
});
watch(() => props.minPxPerSec, (v: any) => {
if (ws && typeof v === 'number' && v > 0) ws.zoom(v);
});
watch(() => currentTime.value, (v: any) => {
// Round-trip guard: skip if the incoming value already matches the engine
// position (the timeupdate → $model → $watch echo), else seek.
if (!ws || typeof v !== 'number') return;
if (Math.abs(v - ws.getCurrentTime()) < 0.05) return;
ws.setTime(v);
});
watch(() => props.timeline, (v: any) => {
if (!ws) return;
if (v && !timelinePlugin) {
timelinePlugin = TimelinePlugin.create();
ws.registerPlugin(timelinePlugin);
} else if (!v && timelinePlugin) {
ws.unregisterPlugin(timelinePlugin);
timelinePlugin = null;
}
});
watch(() => props.hover, (v: any) => {
if (!ws) return;
if (v && !hoverPlugin) {
hoverPlugin = HoverPlugin.create({
lineColor: props.hoverColor ?? undefined
});
ws.registerPlugin(hoverPlugin);
} else if (!v && hoverPlugin) {
ws.unregisterPlugin(hoverPlugin);
hoverPlugin = null;
}
});
watch(() => regions.value, (list: any) => {
// Lazy registration: `regions` transitioned to an array after mount and the
// plugin doesn't exist yet — register it now. If the engine has already
// decoded audio (wsReady), open the reconcile gate immediately; otherwise
// `ready`'s own catch-up (above) opens it once duration is known.
if (Array.isArray(list) && !regionsPlugin && ws) {
ensureRegionsPlugin();
if (wsReady) regionsReady = true;
}
// Controlled reconcile of the live regions to match the incoming list.
// Gated on `regionsReady` (duration known) and value-equality-guarded inside
// reconcileRegions so a writeback echo doesn't loop.
if (!regionsReady) return;
reconcileRegions(list);
});
defineExpose({ play, pause, playPause, stop, seekTo, setTime, setVolume, setPlaybackRate, setZoom, load, isPlaying, getDuration, getCurrentTime, getWaveSurfer, addRegion, clearRegions, getRegions });
</script>
<style scoped>
.rozie-waveform {
width: 100%;
}
</style>svelte
<script lang="ts">
import { applyListeners } from '@rozie/runtime-svelte';
import { onMount, untrack } from 'svelte';
interface Props {
/**
* The audio URL the waveform loads. Bound at construction and reconciled at runtime — changing it calls the engine `load(url)`.
* @example
* <Waveform :src="audioUrl" r-model:currentTime="time" />
*/
src?: (string) | null;
/**
* 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.
*/
peaks?: unknown;
/**
* 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.
*/
duration?: (number) | null;
/**
* The waveform height in pixels. Reconciled at runtime via `setOptions`.
*/
height?: number;
/**
* The color of the unplayed portion of the waveform. Reconciled at runtime via `setOptions`.
*/
waveColor?: string;
/**
* The color of the played (progress) portion of the waveform. Reconciled at runtime via `setOptions`.
*/
progressColor?: string;
/**
* The color of the playback cursor. Reconciled at runtime via `setOptions`.
*/
cursorColor?: string;
/**
* The width of the playback cursor in pixels. Reconciled at runtime via `setOptions`.
*/
cursorWidth?: number;
/**
* Draw the waveform as bars of this pixel width. `null` (default) renders a continuous waveform. Reconciled at runtime via `setOptions`.
*/
barWidth?: (unknown) | null;
/**
* The pixel gap between bars (when `barWidth` is set). Reconciled at runtime via `setOptions`.
*/
barGap?: (unknown) | null;
/**
* The corner radius of bars (when `barWidth` is set). Reconciled at runtime via `setOptions`.
*/
barRadius?: (unknown) | null;
/**
* The minimum pixels-per-second zoom level. Reconciled at runtime via `zoom`.
*/
minPxPerSec?: number;
/**
* Playback volume (`0`–`1`). Reconciled at runtime via `setVolume`.
*/
volume?: number;
/**
* Playback speed multiplier. Reconciled at runtime via `setPlaybackRate`.
*/
playbackRate?: number;
/**
* Begin playback as soon as the audio is ready. Construction-only.
*/
autoplay?: boolean;
/**
* Normalize the waveform by its largest peak (wavesurfer's `normalize` option). Reconciled at runtime via `setOptions`.
*/
normalizeAmplitude?: boolean;
/**
* Hide the horizontal scrollbar when the waveform is zoomed wider than its container. Construction-only.
*/
hideScrollbar?: boolean;
/**
* Disable click/seek interaction with the waveform (the engine defaults to interactive). Construction-only.
*/
disableInteraction?: boolean;
/**
* Disable drag-to-seek across the waveform (the engine defaults to drag-seekable). Construction-only.
*/
disableDragToSeek?: boolean;
/**
* Render a time-ruler beneath the waveform (the wavesurfer Timeline plugin). Live-toggleable — registers/unregisters on the running engine, no remount.
*/
timeline?: boolean;
/**
* Show a hover cursor with a time label as the pointer moves over the waveform (the wavesurfer Hover plugin). Live-toggleable — registers/unregisters on the running engine, no remount.
*/
hover?: boolean;
/**
* The line color of the Hover plugin cursor (only applies when `hover` is enabled). Read/applied when the Hover plugin is (re-)created — not live on an already-registered instance.
*/
hoverColor?: (string) | null;
/**
* The interactive regions as an array of `{ id?, start, end?, content?, color?, drag?, resize? }`. Providing an array (even empty) registers the Regions plugin — at construction if it's already an array, or lazily the first time `regions` transitions from `null`/`undefined` to an array. Two-way (`model: true`): user create / drag / resize / remove writes the updated array back (round-trip-guarded); a consumer write reconciles the live regions (add / update / remove by `id`).
*/
regions?: unknown;
/**
* Allow drawing new regions by dragging over empty waveform space (Regions plugin `enableDragSelection`). Requires `regions` to be an array. Read/applied when the Regions plugin is (re-)created — not live on an already-registered instance.
*/
dragToCreateRegions?: boolean;
/**
* Default fill color for drag-created regions (only applies when `dragToCreateRegions` is on). Read/applied when the Regions plugin is (re-)created — not live on an already-registered instance.
*/
regionColor?: (string) | null;
/**
* 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`, …).
*/
options?: any;
/**
* The current playback position in seconds. The lone two-way `model: true` prop: playback writes the live position back on every `timeupdate` (round-trip-guarded so a programmatic write does not ping-pong), and a consumer write seeks the engine via `setTime`.
*/
currentTime?: unknown;
onregioncreated?: (...args: unknown[]) => void;
onregionupdated?: (...args: unknown[]) => void;
onregionremoved?: (...args: unknown[]) => void;
onregionclicked?: (...args: unknown[]) => void;
onregionin?: (...args: unknown[]) => void;
onregionout?: (...args: unknown[]) => void;
onready?: (...args: unknown[]) => void;
onplaying?: (...args: unknown[]) => void;
onpaused?: (...args: unknown[]) => void;
onfinished?: (...args: unknown[]) => void;
ontimeupdate?: (...args: unknown[]) => void;
onseeking?: (...args: unknown[]) => void;
oninteraction?: (...args: unknown[]) => void;
onloading?: (...args: unknown[]) => void;
onerror?: (...args: unknown[]) => void;
[key: string]: unknown;
}
let __defaultOptions = (() => ({}))();
let {
src = null,
peaks = undefined,
duration = null,
height = 128,
waveColor = '#8a2be2',
progressColor = '#5a189a',
cursorColor = '#333333',
cursorWidth = 1,
barWidth = null,
barGap = null,
barRadius = null,
minPxPerSec = 1,
volume = 1,
playbackRate = 1,
autoplay = false,
normalizeAmplitude = false,
hideScrollbar = false,
disableInteraction = false,
disableDragToSeek = false,
timeline = false,
hover = false,
hoverColor = null,
regions = $bindable(undefined),
dragToCreateRegions = false,
regionColor = null,
options = __defaultOptions,
currentTime = $bindable(undefined),
onregioncreated,
onregionupdated,
onregionremoved,
onregionclicked,
onregionin,
onregionout,
onready,
onplaying,
onpaused,
onfinished,
ontimeupdate,
onseeking,
oninteraction,
onloading,
onerror,
...__rozieAttrs
}: Props = $props();
let container = $state<HTMLElement | undefined>(undefined);
// Default import is `WaveSurfer` (≠ the component name `Waveform`, so no import⇄
// component collision — Cropper had to alias; we don't). Plugin factories are
// separate v7 entry points.
import WaveSurfer from 'wavesurfer.js';
import TimelinePlugin from 'wavesurfer.js/plugins/timeline';
import HoverPlugin from 'wavesurfer.js/plugins/hover';
import RegionsPlugin from 'wavesurfer.js/plugins/regions';
// null-let so the bundled-leaf typeNeutralize pass annotates it `any`: the engine's
// strict WaveSurferOptions/return types don't match the loosely-typed .rozie props,
// and (per the engine-wrapper recipe) `ws` must be TOP-LEVEL — the Solid emitter
// splits $onMount into onMount(...) + onCleanup(...), so a mount-local `let` would
// be out of scope in teardown (TS2304).
let ws: any = null;
// Regions plugin instance + its two guards (all top-level for the Solid teardown
// scope, same reason as `ws`). `regionsReady` gates the reconcile until the audio
// is decoded (addRegion needs a known duration). `reconciling` is the re-entrancy
// guard: while a controlled reconcile mutates the engine, the region-event
// handlers must NOT emit or write back (that would fight the incoming update) —
// only genuine USER edits (outside reconcile) drive the model + emits.
let regionsPlugin: any = null;
let regionsReady = false;
let reconciling = false;
// timelinePlugin / hoverPlugin (live plugin-presence toggling) — top-level so
// the $watch(timeline)/$watch(hover) blocks below can register/unregister them
// on the running engine. wsReady tracks "the engine has decoded audio and
// fired `ready`", independent of whether a regions plugin exists — it gates
// the rare async-window lazy-registration case in the `ready` handler below.
let timelinePlugin: any = null;
let hoverPlugin: any = null;
let wsReady = false;
// Serialize an engine Region to the plain descriptor shape the two-way `regions`
// model carries. Pure (no sigils) — safe at top level.
const serializeRegion = (r: any) => ({
id: r.id,
start: r.start,
end: r.end,
color: r.color,
content: r.content && r.content.textContent ? r.content.textContent : undefined,
drag: r.drag,
resize: r.resize
});
// Value-equality guard (by id + rounded start/end) that stops the
// user-edit → writeback → $model.regions → $watch → reconcile loop from
// oscillating (the Cropper `sameData` idiom, generalized to a list).
const sameRegions = (list: any, engineRegions: any) => {
if (!Array.isArray(list) || list.length !== engineRegions.length) return false;
const key = (r: any) => `${r.id}:${Math.round((r.start ?? 0) * 1000)}:${Math.round((r.end ?? 0) * 1000)}`;
const a = list.map(key).sort();
const b = engineRegions.map(key).sort();
return a.every((k: any, i: any) => k === b[i]);
};
// Push the live engine regions back into the two-way `regions` model (serialized).
// No-op while `reconciling` — a controlled update must not echo back onto itself.
const writeBackRegions = () => {
if (!regionsPlugin || reconciling) return;
regions = regionsPlugin.getRegions().map(serializeRegion);
};
// Reconcile the live engine regions to match a consumer-provided descriptor list:
// update-by-id, add the new, remove the missing. Guarded by `reconciling` so the
// add/remove/setOptions calls don't trigger writeBackRegions mid-flight. If any
// region was added WITHOUT a consumer id, echo the engine state (now carrying the
// assigned ids) back once so the two-way binding gains them.
const reconcileRegions = (list: any) => {
if (!regionsPlugin || !Array.isArray(list)) return;
const current = regionsPlugin.getRegions();
if (sameRegions(list, current)) return;
reconciling = true;
let addedWithoutId = false;
// Build the id→region map with a no-arg `new Map()` (infers Map<any, any>) — a
// `new Map(current.map(...))` over the `any`-typed engine list infers
// Map<unknown, unknown>, so `.setOptions` would fail the strict leaf typecheck.
const byId = new Map();
for (const r of current as any) byId.set(r.id, r);
const keep = new Set();
for (const desc of list as any) {
if (!desc || typeof desc.start !== 'number') continue;
if (desc.id != null && byId.has(desc.id)) {
byId.get(desc.id).setOptions({
start: desc.start,
end: desc.end,
color: desc.color,
drag: desc.drag,
resize: desc.resize,
content: desc.content
});
keep.add(desc.id);
} else {
const created = regionsPlugin.addRegion({
id: desc.id,
start: desc.start,
end: desc.end,
color: desc.color,
content: desc.content,
drag: desc.drag,
resize: desc.resize
});
keep.add(created.id);
if (desc.id == null) addedWithoutId = true;
}
}
for (const r of current as any) {
if (!keep.has(r.id)) r.remove();
}
reconciling = false;
if (addedWithoutId) writeBackRegions();
};
// Attach the 6 region-event listeners to a live RegionsPlugin instance — shared
// by the construction-time path (buildWaveSurfer) and the lazy path
// (ensureRegionsPlugin) so both register identical behavior through one code
// path. Each writeback/emit is a no-op during a controlled reconcile (the
// `reconciling` guard) so a programmatic add/update/remove does not echo back
// or double-emit; only genuine user gestures (drag-create, drag/resize,
// delete) drive the model + emits.
const wireRegionsPluginEvents = (plugin: any) => {
plugin.on('region-created', (region: any) => {
if (reconciling) return;
onregioncreated?.(serializeRegion(region));
writeBackRegions();
});
plugin.on('region-updated', (region: any) => {
if (reconciling) return;
onregionupdated?.(serializeRegion(region));
writeBackRegions();
});
plugin.on('region-removed', (region: any) => {
if (reconciling) return;
onregionremoved?.(serializeRegion(region));
writeBackRegions();
});
plugin.on('region-clicked', (region: any) => {
onregionclicked?.(serializeRegion(region));
});
// Playback entered/left a region — pure notifications (no writeback), so they
// fire regardless of the reconcile guard. The events for active-segment
// highlighting, transcript/karaoke sync, and loop-a-region.
plugin.on('region-in', (region: any) => {
onregionin?.(serializeRegion(region));
});
plugin.on('region-out', (region: any) => {
onregionout?.(serializeRegion(region));
});
};
// Lazily register the Regions plugin on the LIVE engine (idempotent — a no-op
// if it already exists or the engine isn't built yet). Shared by the `ready`
// handler's async-window catch-up and the $watch(regions) transition-to-array
// path, so `regions` flipping from null/undefined to an array after mount
// registers the plugin without a remount.
const ensureRegionsPlugin = () => {
if (regionsPlugin || !ws) return regionsPlugin;
regionsPlugin = RegionsPlugin.create();
ws.registerPlugin(regionsPlugin);
wireRegionsPluginEvents(regionsPlugin);
if (dragToCreateRegions) {
regionsPlugin.enableDragSelection({
color: regionColor ?? undefined
});
}
return regionsPlugin;
};
// Build the engine. The whole config object is untyped (ws is `any`) so the
// constructor's options + event-callback params are unchecked against wavesurfer's
// strict types (the Cropper buildCropper idiom).
const buildWaveSurfer = () => {
let plugins = [];
plugins = [];
if (timeline) {
timelinePlugin = TimelinePlugin.create();
plugins.push(timelinePlugin);
}
if (hover) {
hoverPlugin = HoverPlugin.create({
lineColor: hoverColor ?? undefined
});
plugins.push(hoverPlugin);
}
// Regions plugin is registered when `regions` is an array (even empty).
regionsPlugin = null;
if (Array.isArray(regions)) {
regionsPlugin = RegionsPlugin.create();
plugins.push(regionsPlugin);
}
let cfg: any = null;
cfg = {
...$state.snapshot(options),
container: container,
url: src ?? undefined,
height: height,
waveColor: waveColor,
progressColor: progressColor,
cursorColor: cursorColor,
cursorWidth: cursorWidth,
barWidth: barWidth ?? undefined,
barGap: barGap ?? undefined,
barRadius: barRadius ?? undefined,
minPxPerSec: minPxPerSec,
autoplay: autoplay,
normalize: normalizeAmplitude,
hideScrollbar: hideScrollbar,
interact: !disableInteraction,
dragToSeek: !disableDragToSeek,
plugins: plugins
};
// peaks/duration override the `options` bag ONLY when actually provided —
// assigning `undefined` unconditionally would clobber a caller's options.peaks.
if (peaks != null) cfg.peaks = $state.snapshot(peaks);
if (duration != null) cfg.duration = duration;
ws = WaveSurfer.create(cfg);
// ── engine events → emits + the two-way currentTime writeback ──────────────
ws.on('ready', (duration: any) => {
wsReady = true;
// Rare async-window catch-up: `regions` became an array between mount and
// `ready` firing, before `wsReady` was true, so the $watch(regions) lazy
// path below couldn't gate on it yet. ensureRegionsPlugin is idempotent.
if (Array.isArray(regions)) ensureRegionsPlugin();
// Regions can only be placed once the duration is known — do the initial
// reconcile + drag-selection wiring here, then open the gate for prop-driven
// reconciles. ($watch is lazy, so it never fires at mount; this is the only
// place initial regions get added.)
if (regionsPlugin) {
regionsReady = true;
if (dragToCreateRegions) {
regionsPlugin.enableDragSelection({
color: regionColor ?? undefined
});
}
reconcileRegions($state.snapshot(regions));
}
onready?.(duration);
});
ws.on('play', () => onplaying?.());
ws.on('pause', () => onpaused?.());
ws.on('finish', () => onfinished?.());
ws.on('timeupdate', (t: any) => {
// Echo the live position into the two-way model, then emit. The reverse
// $watch below is value-equality-guarded, so this write does not loop.
currentTime = t;
ontimeupdate?.(t);
});
ws.on('seeking', (t: any) => onseeking?.(t));
ws.on('interaction', (t: any) => oninteraction?.(t));
ws.on('loading', (percent: any) => onloading?.(percent));
ws.on('error', (err: any) => onerror?.(err));
// ── regions plugin events ───────────────────────────────────────────────────
// Shared with the lazy ensureRegionsPlugin() path so construction-time and
// lazy registration wire identical listener behavior through one function.
if (regionsPlugin) wireRegionsPluginEvents(regionsPlugin);
};
// ─── imperative handle (Phase 21 $expose) ────────────────────────────────────
// Collision-clear across all six targets: canonical media verbs play/pause/
// playPause kept (the emits were renamed playing/paused/finished to dodge ROZ121);
// no setCurrentTime (React model auto-setter, ROZ524 — use setTime); no Lit
// reserved lifecycle name (update/render/firstUpdated/updated/willUpdate/requestUpdate).
export function play() {
if (ws) ws.play();
}
export function pause() {
if (ws) ws.pause();
}
export function playPause() {
if (ws) ws.playPause();
}
export function stop() {
if (ws) ws.stop();
}
export function seekTo(progress: any) {
if (ws) ws.seekTo(progress);
}
export function setTime(seconds: any) {
if (ws) ws.setTime(seconds);
}
export function setVolume(v: any) {
if (ws) ws.setVolume(v);
}
export function setPlaybackRate(rate: any) {
if (ws) ws.setPlaybackRate(rate);
}
export function setZoom(pxPerSec: any) {
if (ws) ws.zoom(pxPerSec);
}
export function load(url: any) {
if (ws) ws.load(url);
}
export function isPlaying() {
return ws ? ws.isPlaying() : false;
}
export function getDuration() {
return ws ? ws.getDuration() : 0;
}
export function getCurrentTime() {
return ws ? ws.getCurrentTime() : 0;
}
export function getWaveSurfer() {
return ws;
}
// Regions imperative surface (active only when the `regions` array registered the
// plugin). `addRegion` returns the created engine Region; NO `setRegions` verb
// (the React `regions`-model auto-setter, ROZ524 — drive the list via the two-way
// binding instead).
export function addRegion(opts: any) {
return regionsPlugin ? regionsPlugin.addRegion(opts) : null;
}
export function clearRegions() {
if (regionsPlugin) regionsPlugin.clearRegions();
}
export function getRegions() {
return regionsPlugin ? regionsPlugin.getRegions() : [];
}
onMount(() => {
// $refs read ONLY here (ROZ123). The container is the engine's attach target.
buildWaveSurfer();
return () => {
if (ws) ws.destroy();
};
});
let __rozieWatchInitial_0 = true;
$effect(() => { const __watchVal = (() => src)(); untrack(() => { if (__rozieWatchInitial_0) { __rozieWatchInitial_0 = false; return; } ((v: any) => {
if (ws && typeof v === 'string' && v) ws.load(v);
})(__watchVal); }); });
let __rozieWatchInitial_1 = true;
$effect(() => { const __watchVal = (() => height)(); untrack(() => { if (__rozieWatchInitial_1) { __rozieWatchInitial_1 = false; return; } ((v: any) => {
if (ws) ws.setOptions({
height: v
});
})(__watchVal); }); });
let __rozieWatchInitial_2 = true;
$effect(() => { const __watchVal = (() => waveColor)(); untrack(() => { if (__rozieWatchInitial_2) { __rozieWatchInitial_2 = false; return; } ((v: any) => {
if (ws) ws.setOptions({
waveColor: v
});
})(__watchVal); }); });
let __rozieWatchInitial_3 = true;
$effect(() => { const __watchVal = (() => progressColor)(); untrack(() => { if (__rozieWatchInitial_3) { __rozieWatchInitial_3 = false; return; } ((v: any) => {
if (ws) ws.setOptions({
progressColor: v
});
})(__watchVal); }); });
let __rozieWatchInitial_4 = true;
$effect(() => { const __watchVal = (() => cursorColor)(); untrack(() => { if (__rozieWatchInitial_4) { __rozieWatchInitial_4 = false; return; } ((v: any) => {
if (ws) ws.setOptions({
cursorColor: v
});
})(__watchVal); }); });
let __rozieWatchInitial_5 = true;
$effect(() => { const __watchVal = (() => cursorWidth)(); untrack(() => { if (__rozieWatchInitial_5) { __rozieWatchInitial_5 = false; return; } ((v: any) => {
if (ws) ws.setOptions({
cursorWidth: v
});
})(__watchVal); }); });
let __rozieWatchInitial_6 = true;
$effect(() => { const __watchVal = (() => barWidth)(); untrack(() => { if (__rozieWatchInitial_6) { __rozieWatchInitial_6 = false; return; } ((v: any) => {
if (ws) ws.setOptions({
barWidth: v ?? undefined
});
})(__watchVal); }); });
let __rozieWatchInitial_7 = true;
$effect(() => { const __watchVal = (() => barGap)(); untrack(() => { if (__rozieWatchInitial_7) { __rozieWatchInitial_7 = false; return; } ((v: any) => {
if (ws) ws.setOptions({
barGap: v ?? undefined
});
})(__watchVal); }); });
let __rozieWatchInitial_8 = true;
$effect(() => { const __watchVal = (() => barRadius)(); untrack(() => { if (__rozieWatchInitial_8) { __rozieWatchInitial_8 = false; return; } ((v: any) => {
if (ws) ws.setOptions({
barRadius: v ?? undefined
});
})(__watchVal); }); });
let __rozieWatchInitial_9 = true;
$effect(() => { const __watchVal = (() => normalizeAmplitude)(); untrack(() => { if (__rozieWatchInitial_9) { __rozieWatchInitial_9 = false; return; } ((v: any) => {
if (ws) ws.setOptions({
normalize: v
});
})(__watchVal); }); });
let __rozieWatchInitial_10 = true;
$effect(() => { const __watchVal = (() => volume)(); untrack(() => { if (__rozieWatchInitial_10) { __rozieWatchInitial_10 = false; return; } ((v: any) => {
if (ws && typeof v === 'number') ws.setVolume(v);
})(__watchVal); }); });
let __rozieWatchInitial_11 = true;
$effect(() => { const __watchVal = (() => playbackRate)(); untrack(() => { if (__rozieWatchInitial_11) { __rozieWatchInitial_11 = false; return; } ((v: any) => {
if (ws && typeof v === 'number') ws.setPlaybackRate(v);
})(__watchVal); }); });
let __rozieWatchInitial_12 = true;
$effect(() => { const __watchVal = (() => minPxPerSec)(); untrack(() => { if (__rozieWatchInitial_12) { __rozieWatchInitial_12 = false; return; } ((v: any) => {
if (ws && typeof v === 'number' && v > 0) ws.zoom(v);
})(__watchVal); }); });
let __rozieWatchInitial_13 = true;
$effect(() => { const __watchVal = (() => currentTime)(); untrack(() => { if (__rozieWatchInitial_13) { __rozieWatchInitial_13 = false; return; } ((v: any) => {
// Round-trip guard: skip if the incoming value already matches the engine
// position (the timeupdate → $model → $watch echo), else seek.
if (!ws || typeof v !== 'number') return;
if (Math.abs(v - ws.getCurrentTime()) < 0.05) return;
ws.setTime(v);
})(__watchVal); }); });
let __rozieWatchInitial_14 = true;
$effect(() => { const __watchVal = (() => timeline)(); untrack(() => { if (__rozieWatchInitial_14) { __rozieWatchInitial_14 = false; return; } ((v: any) => {
if (!ws) return;
if (v && !timelinePlugin) {
timelinePlugin = TimelinePlugin.create();
ws.registerPlugin(timelinePlugin);
} else if (!v && timelinePlugin) {
ws.unregisterPlugin(timelinePlugin);
timelinePlugin = null;
}
})(__watchVal); }); });
let __rozieWatchInitial_15 = true;
$effect(() => { const __watchVal = (() => hover)(); untrack(() => { if (__rozieWatchInitial_15) { __rozieWatchInitial_15 = false; return; } ((v: any) => {
if (!ws) return;
if (v && !hoverPlugin) {
hoverPlugin = HoverPlugin.create({
lineColor: hoverColor ?? undefined
});
ws.registerPlugin(hoverPlugin);
} else if (!v && hoverPlugin) {
ws.unregisterPlugin(hoverPlugin);
hoverPlugin = null;
}
})(__watchVal); }); });
let __rozieWatchInitial_16 = true;
$effect(() => { const __watchVal = (() => regions)(); untrack(() => { if (__rozieWatchInitial_16) { __rozieWatchInitial_16 = false; return; } ((list: any) => {
// Lazy registration: `regions` transitioned to an array after mount and the
// plugin doesn't exist yet — register it now. If the engine has already
// decoded audio (wsReady), open the reconcile gate immediately; otherwise
// `ready`'s own catch-up (above) opens it once duration is known.
if (Array.isArray(list) && !regionsPlugin && ws) {
ensureRegionsPlugin();
if (wsReady) regionsReady = true;
}
// Controlled reconcile of the live regions to match the incoming list.
// Gated on `regionsReady` (duration known) and value-equality-guarded inside
// reconcileRegions so a writeback echo doesn't loop.
if (!regionsReady) return;
reconcileRegions($state.snapshot(list));
})(__watchVal); }); });
</script>
<div bind:this={container} {...__rozieAttrs} class={["rozie-waveform", (__rozieAttrs)?.class]} use:applyListeners={__rozieAttrs} data-rozie-s-0b6fbb3a></div>
<style>
:global {
.rozie-waveform[data-rozie-s-0b6fbb3a] {
width: 100%;
}
}
</style>ts
import { Component, DestroyRef, ElementRef, Renderer2, ViewEncapsulation, afterRenderEffect, effect, inject, input, model, output, untracked, viewChild } from '@angular/core';
// Default import is `WaveSurfer` (≠ the component name `Waveform`, so no import⇄
// component collision — Cropper had to alias; we don't). Plugin factories are
// separate v7 entry points.
import WaveSurfer from 'wavesurfer.js';
import TimelinePlugin from 'wavesurfer.js/plugins/timeline';
import HoverPlugin from 'wavesurfer.js/plugins/hover';
import RegionsPlugin from 'wavesurfer.js/plugins/regions';
// null-let so the bundled-leaf typeNeutralize pass annotates it `any`: the engine's
// strict WaveSurferOptions/return types don't match the loosely-typed .rozie props,
// and (per the engine-wrapper recipe) `ws` must be TOP-LEVEL — the Solid emitter
// splits $onMount into onMount(...) + onCleanup(...), so a mount-local `let` would
// be out of scope in teardown (TS2304).
@Component({
selector: 'rozie-waveform',
standalone: true,
template: `
<div class="rozie-waveform" #container #rozieSpread_0 #rozieListenersTarget_1></div>
`,
styles: [`
:host(rozie-waveform) { display: contents; }
.rozie-waveform {
width: 100%;
}
`],
})
export class Waveform {
/**
* The audio URL the waveform loads. Bound at construction and reconciled at runtime — changing it calls the engine `load(url)`.
* @example
* <Waveform :src="audioUrl" r-model:currentTime="time" />
*/
src = input<(string) | null>(null);
/**
* 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.
*/
peaks = input<unknown>(undefined);
/**
* 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.
*/
duration = input<(number) | null>(null);
/**
* The waveform height in pixels. Reconciled at runtime via `setOptions`.
*/
height = input<number>(128);
/**
* The color of the unplayed portion of the waveform. Reconciled at runtime via `setOptions`.
*/
waveColor = input<string>('#8a2be2');
/**
* The color of the played (progress) portion of the waveform. Reconciled at runtime via `setOptions`.
*/
progressColor = input<string>('#5a189a');
/**
* The color of the playback cursor. Reconciled at runtime via `setOptions`.
*/
cursorColor = input<string>('#333333');
/**
* The width of the playback cursor in pixels. Reconciled at runtime via `setOptions`.
*/
cursorWidth = input<number>(1);
/**
* Draw the waveform as bars of this pixel width. `null` (default) renders a continuous waveform. Reconciled at runtime via `setOptions`.
*/
barWidth = input<(unknown) | null>(null);
/**
* The pixel gap between bars (when `barWidth` is set). Reconciled at runtime via `setOptions`.
*/
barGap = input<(unknown) | null>(null);
/**
* The corner radius of bars (when `barWidth` is set). Reconciled at runtime via `setOptions`.
*/
barRadius = input<(unknown) | null>(null);
/**
* The minimum pixels-per-second zoom level. Reconciled at runtime via `zoom`.
*/
minPxPerSec = input<number>(1);
/**
* Playback volume (`0`–`1`). Reconciled at runtime via `setVolume`.
*/
volume = input<number>(1);
/**
* Playback speed multiplier. Reconciled at runtime via `setPlaybackRate`.
*/
playbackRate = input<number>(1);
/**
* Begin playback as soon as the audio is ready. Construction-only.
*/
autoplay = input<boolean>(false);
/**
* Normalize the waveform by its largest peak (wavesurfer's `normalize` option). Reconciled at runtime via `setOptions`.
*/
normalizeAmplitude = input<boolean>(false);
/**
* Hide the horizontal scrollbar when the waveform is zoomed wider than its container. Construction-only.
*/
hideScrollbar = input<boolean>(false);
/**
* Disable click/seek interaction with the waveform (the engine defaults to interactive). Construction-only.
*/
disableInteraction = input<boolean>(false);
/**
* Disable drag-to-seek across the waveform (the engine defaults to drag-seekable). Construction-only.
*/
disableDragToSeek = input<boolean>(false);
/**
* Render a time-ruler beneath the waveform (the wavesurfer Timeline plugin). Live-toggleable — registers/unregisters on the running engine, no remount.
*/
timeline = input<boolean>(false);
/**
* Show a hover cursor with a time label as the pointer moves over the waveform (the wavesurfer Hover plugin). Live-toggleable — registers/unregisters on the running engine, no remount.
*/
hover = input<boolean>(false);
/**
* The line color of the Hover plugin cursor (only applies when `hover` is enabled). Read/applied when the Hover plugin is (re-)created — not live on an already-registered instance.
*/
hoverColor = input<(string) | null>(null);
/**
* The interactive regions as an array of `{ id?, start, end?, content?, color?, drag?, resize? }`. Providing an array (even empty) registers the Regions plugin — at construction if it's already an array, or lazily the first time `regions` transitions from `null`/`undefined` to an array. Two-way (`model: true`): user create / drag / resize / remove writes the updated array back (round-trip-guarded); a consumer write reconciles the live regions (add / update / remove by `id`).
*/
regions = model<unknown>(undefined);
/**
* Allow drawing new regions by dragging over empty waveform space (Regions plugin `enableDragSelection`). Requires `regions` to be an array. Read/applied when the Regions plugin is (re-)created — not live on an already-registered instance.
*/
dragToCreateRegions = input<boolean>(false);
/**
* Default fill color for drag-created regions (only applies when `dragToCreateRegions` is on). Read/applied when the Regions plugin is (re-)created — not live on an already-registered instance.
*/
regionColor = input<(string) | null>(null);
/**
* 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`, …).
*/
options = input<Record<string, any>>((() => ({}))());
/**
* The current playback position in seconds. The lone two-way `model: true` prop: playback writes the live position back on every `timeupdate` (round-trip-guarded so a programmatic write does not ping-pong), and a consumer write seeks the engine via `setTime`.
*/
currentTime = model<unknown>(undefined);
container = viewChild<ElementRef<HTMLDivElement>>('container');
regionCreated = output<unknown>();
regionUpdated = output<unknown>();
regionRemoved = output<unknown>();
regionClicked = output<unknown>();
regionIn = output<unknown>();
regionOut = output<unknown>();
ready = output<unknown>();
playing = output<void>();
paused = output<void>();
finished = output<void>();
timeupdate = output<unknown>();
seeking = output<unknown>();
interaction = output<unknown>();
loading = output<unknown>();
error = output<unknown>();
private __rozieDestroyRef = inject(DestroyRef);
private __rozieWatchInitial_0 = true;
private __rozieWatchInitial_1 = true;
private __rozieWatchInitial_2 = true;
private __rozieWatchInitial_3 = true;
private __rozieWatchInitial_4 = true;
private __rozieWatchInitial_5 = true;
private __rozieWatchInitial_6 = true;
private __rozieWatchInitial_7 = true;
private __rozieWatchInitial_8 = true;
private __rozieWatchInitial_9 = true;
private __rozieWatchInitial_10 = true;
private __rozieWatchInitial_11 = true;
private __rozieWatchInitial_12 = true;
private __rozieWatchInitial_13 = true;
private __rozieWatchInitial_14 = true;
private __rozieWatchInitial_15 = true;
private __rozieWatchInitial_16 = true;
constructor() {
effect(() => { const __watchVal = (() => this.src())(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } ((v: any) => {
if (this.ws && typeof v === 'string' && v) this.ws.load(v);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.height())(); untracked(() => { if (this.__rozieWatchInitial_1) { this.__rozieWatchInitial_1 = false; return; } ((v: any) => {
if (this.ws) this.ws.setOptions({
height: v
});
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.waveColor())(); untracked(() => { if (this.__rozieWatchInitial_2) { this.__rozieWatchInitial_2 = false; return; } ((v: any) => {
if (this.ws) this.ws.setOptions({
waveColor: v
});
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.progressColor())(); untracked(() => { if (this.__rozieWatchInitial_3) { this.__rozieWatchInitial_3 = false; return; } ((v: any) => {
if (this.ws) this.ws.setOptions({
progressColor: v
});
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.cursorColor())(); untracked(() => { if (this.__rozieWatchInitial_4) { this.__rozieWatchInitial_4 = false; return; } ((v: any) => {
if (this.ws) this.ws.setOptions({
cursorColor: v
});
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.cursorWidth())(); untracked(() => { if (this.__rozieWatchInitial_5) { this.__rozieWatchInitial_5 = false; return; } ((v: any) => {
if (this.ws) this.ws.setOptions({
cursorWidth: v
});
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.barWidth())(); untracked(() => { if (this.__rozieWatchInitial_6) { this.__rozieWatchInitial_6 = false; return; } ((v: any) => {
if (this.ws) this.ws.setOptions({
barWidth: v ?? undefined
});
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.barGap())(); untracked(() => { if (this.__rozieWatchInitial_7) { this.__rozieWatchInitial_7 = false; return; } ((v: any) => {
if (this.ws) this.ws.setOptions({
barGap: v ?? undefined
});
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.barRadius())(); untracked(() => { if (this.__rozieWatchInitial_8) { this.__rozieWatchInitial_8 = false; return; } ((v: any) => {
if (this.ws) this.ws.setOptions({
barRadius: v ?? undefined
});
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.normalizeAmplitude())(); untracked(() => { if (this.__rozieWatchInitial_9) { this.__rozieWatchInitial_9 = false; return; } ((v: any) => {
if (this.ws) this.ws.setOptions({
normalize: v
});
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.volume())(); untracked(() => { if (this.__rozieWatchInitial_10) { this.__rozieWatchInitial_10 = false; return; } ((v: any) => {
if (this.ws && typeof v === 'number') this.ws.setVolume(v);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.playbackRate())(); untracked(() => { if (this.__rozieWatchInitial_11) { this.__rozieWatchInitial_11 = false; return; } ((v: any) => {
if (this.ws && typeof v === 'number') this.ws.setPlaybackRate(v);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.minPxPerSec())(); untracked(() => { if (this.__rozieWatchInitial_12) { this.__rozieWatchInitial_12 = false; return; } ((v: any) => {
if (this.ws && typeof v === 'number' && v > 0) this.ws.zoom(v);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.currentTime())(); untracked(() => { if (this.__rozieWatchInitial_13) { this.__rozieWatchInitial_13 = false; return; } ((v: any) => {
// Round-trip guard: skip if the incoming value already matches the engine
// position (the timeupdate → $model → $watch echo), else seek.
if (!this.ws || typeof v !== 'number') return;
if (Math.abs(v - this.ws.getCurrentTime()) < 0.05) return;
this.ws.setTime(v);
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.timeline())(); untracked(() => { if (this.__rozieWatchInitial_14) { this.__rozieWatchInitial_14 = false; return; } ((v: any) => {
if (!this.ws) return;
if (v && !this.timelinePlugin) {
this.timelinePlugin = TimelinePlugin.create();
this.ws.registerPlugin(this.timelinePlugin);
} else if (!v && this.timelinePlugin) {
this.ws.unregisterPlugin(this.timelinePlugin);
this.timelinePlugin = null;
}
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.hover())(); untracked(() => { if (this.__rozieWatchInitial_15) { this.__rozieWatchInitial_15 = false; return; } ((v: any) => {
if (!this.ws) return;
if (v && !this.hoverPlugin) {
this.hoverPlugin = HoverPlugin.create({
lineColor: this.hoverColor() ?? undefined
});
this.ws.registerPlugin(this.hoverPlugin);
} else if (!v && this.hoverPlugin) {
this.ws.unregisterPlugin(this.hoverPlugin);
this.hoverPlugin = null;
}
})(__watchVal); }); });
effect(() => { const __watchVal = (() => this.regions())(); untracked(() => { if (this.__rozieWatchInitial_16) { this.__rozieWatchInitial_16 = false; return; } ((list: any) => {
// Lazy registration: `regions` transitioned to an array after mount and the
// plugin doesn't exist yet — register it now. If the engine has already
// decoded audio (wsReady), open the reconcile gate immediately; otherwise
// `ready`'s own catch-up (above) opens it once duration is known.
if (Array.isArray(list) && !this.regionsPlugin && this.ws) {
this.ensureRegionsPlugin();
if (this.wsReady) this.regionsReady = true;
}
// Controlled reconcile of the live regions to match the incoming list.
// Gated on `regionsReady` (duration known) and value-equality-guarded inside
// reconcileRegions so a writeback echo doesn't loop.
if (!this.regionsReady) return;
this.reconcileRegions(list);
})(__watchVal); }); });
}
ngAfterViewInit() {
// $refs read ONLY here (ROZ123). The container is the engine's attach target.
this.buildWaveSurfer();
this.__rozieDestroyRef.onDestroy(() => {
if (this.ws) this.ws.destroy();
});
}
ws: any = null;
regionsPlugin: any = null;
regionsReady = false;
reconciling = false;
timelinePlugin: any = null;
hoverPlugin: any = null;
wsReady = false;
serializeRegion = (r: any) => ({
id: r.id,
start: r.start,
end: r.end,
color: r.color,
content: r.content && r.content.textContent ? r.content.textContent : undefined,
drag: r.drag,
resize: r.resize
});
sameRegions = (list: any, engineRegions: any) => {
if (!Array.isArray(list) || list.length !== engineRegions.length) return false;
const key = (r: any) => `${r.id}:${Math.round((r.start ?? 0) * 1000)}:${Math.round((r.end ?? 0) * 1000)}`;
const a = list.map(key).sort();
const b = engineRegions.map(key).sort();
return a.every((k: any, i: any) => k === b[i]);
};
writeBackRegions = () => {
if (!this.regionsPlugin || this.reconciling) return;
this.regions.set(this.regionsPlugin.getRegions().map(this.serializeRegion));
};
reconcileRegions = (list: any) => {
if (!this.regionsPlugin || !Array.isArray(list)) return;
const current = this.regionsPlugin.getRegions();
if (this.sameRegions(list, current)) return;
this.reconciling = true;
let addedWithoutId = false;
// Build the id→region map with a no-arg `new Map()` (infers Map<any, any>) — a
// `new Map(current.map(...))` over the `any`-typed engine list infers
// Map<unknown, unknown>, so `.setOptions` would fail the strict leaf typecheck.
const byId = new Map();
for (const r of current as any) byId.set(r.id, r);
const keep = new Set();
for (const desc of list as any) {
if (!desc || typeof desc.start !== 'number') continue;
if (desc.id != null && byId.has(desc.id)) {
byId.get(desc.id).setOptions({
start: desc.start,
end: desc.end,
color: desc.color,
drag: desc.drag,
resize: desc.resize,
content: desc.content
});
keep.add(desc.id);
} else {
const created = this.regionsPlugin.addRegion({
id: desc.id,
start: desc.start,
end: desc.end,
color: desc.color,
content: desc.content,
drag: desc.drag,
resize: desc.resize
});
keep.add(created.id);
if (desc.id == null) addedWithoutId = true;
}
}
for (const r of current as any) {
if (!keep.has(r.id)) r.remove();
}
this.reconciling = false;
if (addedWithoutId) this.writeBackRegions();
};
wireRegionsPluginEvents = (plugin: any) => {
plugin.on('region-created', (region: any) => {
if (this.reconciling) return;
this.regionCreated.emit(this.serializeRegion(region));
this.writeBackRegions();
});
plugin.on('region-updated', (region: any) => {
if (this.reconciling) return;
this.regionUpdated.emit(this.serializeRegion(region));
this.writeBackRegions();
});
plugin.on('region-removed', (region: any) => {
if (this.reconciling) return;
this.regionRemoved.emit(this.serializeRegion(region));
this.writeBackRegions();
});
plugin.on('region-clicked', (region: any) => {
this.regionClicked.emit(this.serializeRegion(region));
});
// Playback entered/left a region — pure notifications (no writeback), so they
// fire regardless of the reconcile guard. The events for active-segment
// highlighting, transcript/karaoke sync, and loop-a-region.
plugin.on('region-in', (region: any) => {
this.regionIn.emit(this.serializeRegion(region));
});
plugin.on('region-out', (region: any) => {
this.regionOut.emit(this.serializeRegion(region));
});
};
ensureRegionsPlugin = () => {
if (this.regionsPlugin || !this.ws) return this.regionsPlugin;
this.regionsPlugin = RegionsPlugin.create();
this.ws.registerPlugin(this.regionsPlugin);
this.wireRegionsPluginEvents(this.regionsPlugin);
if (this.dragToCreateRegions()) {
this.regionsPlugin.enableDragSelection({
color: this.regionColor() ?? undefined
});
}
return this.regionsPlugin;
};
buildWaveSurfer = () => {
const __peaks = this.peaks();
const __duration = this.duration();
let plugins = [];
plugins = [];
if (this.timeline()) {
this.timelinePlugin = TimelinePlugin.create();
plugins.push(this.timelinePlugin);
}
if (this.hover()) {
this.hoverPlugin = HoverPlugin.create({
lineColor: this.hoverColor() ?? undefined
});
plugins.push(this.hoverPlugin);
}
// Regions plugin is registered when `regions` is an array (even empty).
this.regionsPlugin = null;
if (Array.isArray(this.regions())) {
this.regionsPlugin = RegionsPlugin.create();
plugins.push(this.regionsPlugin);
}
let cfg: any = null;
cfg = {
...this.options(),
container: this.container()?.nativeElement,
url: this.src() ?? undefined,
height: this.height(),
waveColor: this.waveColor(),
progressColor: this.progressColor(),
cursorColor: this.cursorColor(),
cursorWidth: this.cursorWidth(),
barWidth: this.barWidth() ?? undefined,
barGap: this.barGap() ?? undefined,
barRadius: this.barRadius() ?? undefined,
minPxPerSec: this.minPxPerSec(),
autoplay: this.autoplay(),
normalize: this.normalizeAmplitude(),
hideScrollbar: this.hideScrollbar(),
interact: !this.disableInteraction(),
dragToSeek: !this.disableDragToSeek(),
plugins: plugins
};
// peaks/duration override the `options` bag ONLY when actually provided —
// assigning `undefined` unconditionally would clobber a caller's options.peaks.
if (__peaks != null) cfg.peaks = __peaks;
if (__duration != null) cfg.duration = __duration;
this.ws = WaveSurfer.create(cfg);
// ── engine events → emits + the two-way currentTime writeback ──────────────
this.ws.on('ready', (duration: any) => {
const __regions = this.regions();
this.wsReady = true;
// Rare async-window catch-up: `regions` became an array between mount and
// `ready` firing, before `wsReady` was true, so the $watch(regions) lazy
// path below couldn't gate on it yet. ensureRegionsPlugin is idempotent.
if (Array.isArray(__regions)) this.ensureRegionsPlugin();
// Regions can only be placed once the duration is known — do the initial
// reconcile + drag-selection wiring here, then open the gate for prop-driven
// reconciles. ($watch is lazy, so it never fires at mount; this is the only
// place initial regions get added.)
if (this.regionsPlugin) {
this.regionsReady = true;
if (this.dragToCreateRegions()) {
this.regionsPlugin.enableDragSelection({
color: this.regionColor() ?? undefined
});
}
this.reconcileRegions(__regions);
}
this.ready.emit(duration);
});
this.ws.on('play', () => this.playing.emit());
this.ws.on('pause', () => this.paused.emit());
this.ws.on('finish', () => this.finished.emit());
this.ws.on('timeupdate', (t: any) => {
// Echo the live position into the two-way model, then emit. The reverse
// $watch below is value-equality-guarded, so this write does not loop.
this.currentTime.set(t);
this.timeupdate.emit(t);
});
this.ws.on('seeking', (t: any) => this.seeking.emit(t));
this.ws.on('interaction', (t: any) => this.interaction.emit(t));
this.ws.on('loading', (percent: any) => this.loading.emit(percent));
this.ws.on('error', (err: any) => this.error.emit(err));
// ── regions plugin events ───────────────────────────────────────────────────
// Shared with the lazy ensureRegionsPlugin() path so construction-time and
// lazy registration wire identical listener behavior through one function.
if (this.regionsPlugin) this.wireRegionsPluginEvents(this.regionsPlugin);
};
play = () => {
if (this.ws) this.ws.play();
};
pause = () => {
if (this.ws) this.ws.pause();
};
playPause = () => {
if (this.ws) this.ws.playPause();
};
stop = () => {
if (this.ws) this.ws.stop();
};
seekTo = (progress: any) => {
if (this.ws) this.ws.seekTo(progress);
};
setTime = (seconds: any) => {
if (this.ws) this.ws.setTime(seconds);
};
setVolume = (v: any) => {
if (this.ws) this.ws.setVolume(v);
};
setPlaybackRate = (rate: any) => {
if (this.ws) this.ws.setPlaybackRate(rate);
};
setZoom = (pxPerSec: any) => {
if (this.ws) this.ws.zoom(pxPerSec);
};
load = (url: any) => {
if (this.ws) this.ws.load(url);
};
isPlaying = () => {
return this.ws ? this.ws.isPlaying() : false;
};
getDuration = () => {
return this.ws ? this.ws.getDuration() : 0;
};
getCurrentTime = () => {
return this.ws ? this.ws.getCurrentTime() : 0;
};
getWaveSurfer = () => {
return this.ws;
};
addRegion = (opts: any) => {
return this.regionsPlugin ? this.regionsPlugin.addRegion(opts) : null;
};
clearRegions = () => {
if (this.regionsPlugin) this.regionsPlugin.clearRegions();
};
getRegions = () => {
return this.regionsPlugin ? this.regionsPlugin.getRegions() : [];
};
private rozieSpread_0 = viewChild<ElementRef>('rozieSpread_0');
private __rozieApplyAttrs = (() => {
const renderer = inject(Renderer2);
const prevKeysByElement = new WeakMap<HTMLElement, string[]>();
const prevClassTokensByElement = new WeakMap<HTMLElement, string[]>();
const prevStylePropsByElement = new WeakMap<HTMLElement, string[]>();
const parseClassTokens = (value: unknown): string[] => {
if (typeof value !== 'string') return [];
const out: string[] = [];
for (const tok of value.split(/\s+/)) {
if (tok.length > 0) out.push(tok);
}
return out;
};
const parseStyleDecls = (value: unknown): Array<[string, string]> => {
if (typeof value !== 'string') return [];
const out: Array<[string, string]> = [];
for (const decl of value.split(';')) {
const colon = decl.indexOf(':');
if (colon < 0) continue;
const prop = decl.slice(0, colon).trim();
const val = decl.slice(colon + 1).trim();
if (prop.length > 0) out.push([prop, val]);
}
return out;
};
const applyClassMerge = (el: HTMLElement, value: unknown) => {
const next = parseClassTokens(value);
const prev = prevClassTokensByElement.get(el) ?? [];
const nextSet = new Set(next);
for (const tok of prev) {
if (!nextSet.has(tok)) el.classList.remove(tok);
}
for (const tok of next) el.classList.add(tok);
prevClassTokensByElement.set(el, next);
};
const applyStyleMerge = (el: HTMLElement, value: unknown) => {
const next = parseStyleDecls(value);
const prev = prevStylePropsByElement.get(el) ?? [];
const nextProps = next.map(([p]) => p);
const nextSet = new Set(nextProps);
for (const prop of prev) {
if (!nextSet.has(prop)) el.style.removeProperty(prop);
}
for (const [prop, val] of next) el.style.setProperty(prop, val, 'important');
prevStylePropsByElement.set(el, nextProps);
};
return (el: HTMLElement, obj: Record<string, unknown> | null | undefined) => {
const safeObj: Record<string, unknown> = obj ?? {};
const prevKeys = prevKeysByElement.get(el) ?? [];
for (const k of prevKeys) {
if (k === 'class' || k === 'style') continue;
if (!(k in safeObj)) renderer.removeAttribute(el, k);
}
if (!('class' in safeObj) && prevClassTokensByElement.has(el)) {
applyClassMerge(el, '');
}
if (!('style' in safeObj) && prevStylePropsByElement.has(el)) {
applyStyleMerge(el, '');
}
for (const [k, v] of Object.entries(safeObj)) {
if (k === 'class') {
applyClassMerge(el, v);
} else if (k === 'style') {
applyStyleMerge(el, v);
} else if (v === null || v === false) {
renderer.removeAttribute(el, k);
} else {
renderer.setAttribute(el, k, String(v));
}
}
prevKeysByElement.set(el, Object.keys(safeObj));
};
})();
private __rozieGetHostAttrs = (() => {
const host = inject(ElementRef);
return () => {
const el = host.nativeElement as HTMLElement;
const out: Record<string, unknown> = {};
for (const a of Array.from(el.attributes)) out[a.name] = a.value;
return out;
};
})();
private __rozieSpread_0_effect = afterRenderEffect(() => {
const el = this.rozieSpread_0()?.nativeElement;
if (!el) return;
this.__rozieApplyAttrs(el, this.__rozieGetHostAttrs());
});
private rozieListenersTarget_1 = viewChild<ElementRef>('rozieListenersTarget_1');
private __rozieListenersRenderer = inject(Renderer2);
private __rozieListenersDisposers_1: Array<() => void> = [];
private __rozieListenersDestroyRegistered_1 = false;
private __rozieListenersEffect_1 = effect(() => {
const el = this.rozieListenersTarget_1()?.nativeElement;
if (!el) return;
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
const obj: Record<string, unknown> = {};
for (const [k, v] of Object.entries(obj)) {
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
if (typeof v !== 'function') continue;
const norm = k.startsWith('on') ? k.slice(2).toLowerCase() : k;
const dispose = this.__rozieListenersRenderer.listen(el, norm, v as EventListener);
this.__rozieListenersDisposers_1.push(dispose);
}
if (!this.__rozieListenersDestroyRegistered_1) {
this.__rozieListenersDestroyRegistered_1 = true;
this.__rozieDestroyRef.onDestroy(() => {
for (const off of this.__rozieListenersDisposers_1) off();
this.__rozieListenersDisposers_1 = [];
});
}
});
}
export default Waveform;tsx
import type { JSX } from 'solid-js';
import { createEffect, mergeProps, on, onCleanup, onMount, splitProps, untrack } from 'solid-js';
import { __rozieInjectStyle, createControllableSignal } from '@rozie/runtime-solid';
// Default import is `WaveSurfer` (≠ the component name `Waveform`, so no import⇄
// component collision — Cropper had to alias; we don't). Plugin factories are
// separate v7 entry points.
import WaveSurfer from 'wavesurfer.js';
import TimelinePlugin from 'wavesurfer.js/plugins/timeline';
import HoverPlugin from 'wavesurfer.js/plugins/hover';
import RegionsPlugin from 'wavesurfer.js/plugins/regions';
// null-let so the bundled-leaf typeNeutralize pass annotates it `any`: the engine's
// strict WaveSurferOptions/return types don't match the loosely-typed .rozie props,
// and (per the engine-wrapper recipe) `ws` must be TOP-LEVEL — the Solid emitter
// splits $onMount into onMount(...) + onCleanup(...), so a mount-local `let` would
// be out of scope in teardown (TS2304).
__rozieInjectStyle('Waveform-0b6fbb3a', `.rozie-waveform[data-rozie-s-0b6fbb3a] {
width: 100%;
}`);
interface WaveformProps {
/**
* The audio URL the waveform loads. Bound at construction and reconciled at runtime — changing it calls the engine `load(url)`.
* @example
* <Waveform :src="audioUrl" r-model:currentTime="time" />
*/
src?: (string) | null;
/**
* 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.
*/
peaks?: unknown;
/**
* 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.
*/
duration?: (number) | null;
/**
* The waveform height in pixels. Reconciled at runtime via `setOptions`.
*/
height?: number;
/**
* The color of the unplayed portion of the waveform. Reconciled at runtime via `setOptions`.
*/
waveColor?: string;
/**
* The color of the played (progress) portion of the waveform. Reconciled at runtime via `setOptions`.
*/
progressColor?: string;
/**
* The color of the playback cursor. Reconciled at runtime via `setOptions`.
*/
cursorColor?: string;
/**
* The width of the playback cursor in pixels. Reconciled at runtime via `setOptions`.
*/
cursorWidth?: number;
/**
* Draw the waveform as bars of this pixel width. `null` (default) renders a continuous waveform. Reconciled at runtime via `setOptions`.
*/
barWidth?: (unknown) | null;
/**
* The pixel gap between bars (when `barWidth` is set). Reconciled at runtime via `setOptions`.
*/
barGap?: (unknown) | null;
/**
* The corner radius of bars (when `barWidth` is set). Reconciled at runtime via `setOptions`.
*/
barRadius?: (unknown) | null;
/**
* The minimum pixels-per-second zoom level. Reconciled at runtime via `zoom`.
*/
minPxPerSec?: number;
/**
* Playback volume (`0`–`1`). Reconciled at runtime via `setVolume`.
*/
volume?: number;
/**
* Playback speed multiplier. Reconciled at runtime via `setPlaybackRate`.
*/
playbackRate?: number;
/**
* Begin playback as soon as the audio is ready. Construction-only.
*/
autoplay?: boolean;
/**
* Normalize the waveform by its largest peak (wavesurfer's `normalize` option). Reconciled at runtime via `setOptions`.
*/
normalizeAmplitude?: boolean;
/**
* Hide the horizontal scrollbar when the waveform is zoomed wider than its container. Construction-only.
*/
hideScrollbar?: boolean;
/**
* Disable click/seek interaction with the waveform (the engine defaults to interactive). Construction-only.
*/
disableInteraction?: boolean;
/**
* Disable drag-to-seek across the waveform (the engine defaults to drag-seekable). Construction-only.
*/
disableDragToSeek?: boolean;
/**
* Render a time-ruler beneath the waveform (the wavesurfer Timeline plugin). Live-toggleable — registers/unregisters on the running engine, no remount.
*/
timeline?: boolean;
/**
* Show a hover cursor with a time label as the pointer moves over the waveform (the wavesurfer Hover plugin). Live-toggleable — registers/unregisters on the running engine, no remount.
*/
hover?: boolean;
/**
* The line color of the Hover plugin cursor (only applies when `hover` is enabled). Read/applied when the Hover plugin is (re-)created — not live on an already-registered instance.
*/
hoverColor?: (string) | null;
/**
* The interactive regions as an array of `{ id?, start, end?, content?, color?, drag?, resize? }`. Providing an array (even empty) registers the Regions plugin — at construction if it's already an array, or lazily the first time `regions` transitions from `null`/`undefined` to an array. Two-way (`model: true`): user create / drag / resize / remove writes the updated array back (round-trip-guarded); a consumer write reconciles the live regions (add / update / remove by `id`).
*/
regions?: unknown;
defaultRegions?: unknown;
onRegionsChange?: (regions: unknown) => void;
/**
* Allow drawing new regions by dragging over empty waveform space (Regions plugin `enableDragSelection`). Requires `regions` to be an array. Read/applied when the Regions plugin is (re-)created — not live on an already-registered instance.
*/
dragToCreateRegions?: boolean;
/**
* Default fill color for drag-created regions (only applies when `dragToCreateRegions` is on). Read/applied when the Regions plugin is (re-)created — not live on an already-registered instance.
*/
regionColor?: (string) | null;
/**
* 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`, …).
*/
options?: Record<string, any>;
/**
* The current playback position in seconds. The lone two-way `model: true` prop: playback writes the live position back on every `timeupdate` (round-trip-guarded so a programmatic write does not ping-pong), and a consumer write seeks the engine via `setTime`.
*/
currentTime?: unknown;
defaultCurrentTime?: unknown;
onCurrentTimeChange?: (currentTime: unknown) => void;
onRegionCreated?: (...args: unknown[]) => void;
onRegionUpdated?: (...args: unknown[]) => void;
onRegionRemoved?: (...args: unknown[]) => void;
onRegionClicked?: (...args: unknown[]) => void;
onRegionIn?: (...args: unknown[]) => void;
onRegionOut?: (...args: unknown[]) => void;
onReady?: (...args: unknown[]) => void;
onPlaying?: (...args: unknown[]) => void;
onPaused?: (...args: unknown[]) => void;
onFinished?: (...args: unknown[]) => void;
onTimeupdate?: (...args: unknown[]) => void;
onSeeking?: (...args: unknown[]) => void;
onInteraction?: (...args: unknown[]) => void;
onLoading?: (...args: unknown[]) => void;
onError?: (...args: unknown[]) => void;
ref?: (h: WaveformHandle) => void;
}
export interface WaveformHandle {
play: (...args: any[]) => any;
pause: (...args: any[]) => any;
playPause: (...args: any[]) => any;
stop: (...args: any[]) => any;
seekTo: (...args: any[]) => any;
setTime: (...args: any[]) => any;
setVolume: (...args: any[]) => any;
setPlaybackRate: (...args: any[]) => any;
setZoom: (...args: any[]) => any;
load: (...args: any[]) => any;
isPlaying: (...args: any[]) => any;
getDuration: (...args: any[]) => any;
getCurrentTime: (...args: any[]) => any;
getWaveSurfer: (...args: any[]) => any;
addRegion: (...args: any[]) => any;
clearRegions: (...args: any[]) => any;
getRegions: (...args: any[]) => any;
}
export default function Waveform(_props: WaveformProps): JSX.Element {
const _merged = mergeProps({ src: null, peaks: undefined, duration: null, height: 128, waveColor: '#8a2be2', progressColor: '#5a189a', cursorColor: '#333333', cursorWidth: 1, barWidth: null, barGap: null, barRadius: null, minPxPerSec: 1, volume: 1, playbackRate: 1, autoplay: false, normalizeAmplitude: false, hideScrollbar: false, disableInteraction: false, disableDragToSeek: false, timeline: false, hover: false, hoverColor: null, dragToCreateRegions: false, regionColor: null, options: (() => ({}))() as Record<string, any> }, _props);
const [local, attrs] = splitProps(_merged, ['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', 'ref', 'onRegionCreated', 'onRegionUpdated', 'onRegionRemoved', 'onRegionClicked', 'onRegionIn', 'onRegionOut', 'onReady', 'onPlaying', 'onPaused', 'onFinished', 'onTimeupdate', 'onSeeking', 'onInteraction', 'onLoading', 'onError']);
onMount(() => { local.ref?.({ play, pause, playPause, stop, seekTo, setTime, setVolume, setPlaybackRate, setZoom, load, isPlaying, getDuration, getCurrentTime, getWaveSurfer, addRegion, clearRegions, getRegions }); });
const [regions, setRegions] = createControllableSignal<unknown>(_props as unknown as Record<string, unknown>, 'regions', undefined);
const [currentTime, setCurrentTime] = createControllableSignal<unknown>(_props as unknown as Record<string, unknown>, 'currentTime', undefined);
onMount(() => {
const _cleanup = (() => {
// $refs read ONLY here (ROZ123). The container is the engine's attach target.
buildWaveSurfer();
})() as unknown;
if (_cleanup) onCleanup(_cleanup as () => void);
onCleanup(() => {
if (ws) ws.destroy();
});
});
createEffect(on(() => (() => local.src)(), (v) => untrack(() => ((v: any) => {
if (ws && typeof v === 'string' && v) ws.load(v);
})(v)), { defer: true }));
createEffect(on(() => (() => local.height)(), (v) => untrack(() => ((v: any) => {
if (ws) ws.setOptions({
height: v
});
})(v)), { defer: true }));
createEffect(on(() => (() => local.waveColor)(), (v) => untrack(() => ((v: any) => {
if (ws) ws.setOptions({
waveColor: v
});
})(v)), { defer: true }));
createEffect(on(() => (() => local.progressColor)(), (v) => untrack(() => ((v: any) => {
if (ws) ws.setOptions({
progressColor: v
});
})(v)), { defer: true }));
createEffect(on(() => (() => local.cursorColor)(), (v) => untrack(() => ((v: any) => {
if (ws) ws.setOptions({
cursorColor: v
});
})(v)), { defer: true }));
createEffect(on(() => (() => local.cursorWidth)(), (v) => untrack(() => ((v: any) => {
if (ws) ws.setOptions({
cursorWidth: v
});
})(v)), { defer: true }));
createEffect(on(() => (() => local.barWidth)(), (v) => untrack(() => ((v: any) => {
if (ws) ws.setOptions({
barWidth: v ?? undefined
});
})(v)), { defer: true }));
createEffect(on(() => (() => local.barGap)(), (v) => untrack(() => ((v: any) => {
if (ws) ws.setOptions({
barGap: v ?? undefined
});
})(v)), { defer: true }));
createEffect(on(() => (() => local.barRadius)(), (v) => untrack(() => ((v: any) => {
if (ws) ws.setOptions({
barRadius: v ?? undefined
});
})(v)), { defer: true }));
createEffect(on(() => (() => local.normalizeAmplitude)(), (v) => untrack(() => ((v: any) => {
if (ws) ws.setOptions({
normalize: v
});
})(v)), { defer: true }));
createEffect(on(() => (() => local.volume)(), (v) => untrack(() => ((v: any) => {
if (ws && typeof v === 'number') ws.setVolume(v);
})(v)), { defer: true }));
createEffect(on(() => (() => local.playbackRate)(), (v) => untrack(() => ((v: any) => {
if (ws && typeof v === 'number') ws.setPlaybackRate(v);
})(v)), { defer: true }));
createEffect(on(() => (() => local.minPxPerSec)(), (v) => untrack(() => ((v: any) => {
if (ws && typeof v === 'number' && v > 0) ws.zoom(v);
})(v)), { defer: true }));
createEffect(on(() => (() => currentTime())(), (v) => untrack(() => ((v: any) => {
// Round-trip guard: skip if the incoming value already matches the engine
// position (the timeupdate → $model → $watch echo), else seek.
if (!ws || typeof v !== 'number') return;
if (Math.abs(v - ws.getCurrentTime()) < 0.05) return;
ws.setTime(v);
})(v)), { defer: true }));
createEffect(on(() => (() => local.timeline)(), (v) => untrack(() => ((v: any) => {
if (!ws) return;
if (v && !timelinePlugin) {
timelinePlugin = TimelinePlugin.create();
ws.registerPlugin(timelinePlugin);
} else if (!v && timelinePlugin) {
ws.unregisterPlugin(timelinePlugin);
timelinePlugin = null;
}
})(v)), { defer: true }));
createEffect(on(() => (() => local.hover)(), (v) => untrack(() => ((v: any) => {
if (!ws) return;
if (v && !hoverPlugin) {
hoverPlugin = HoverPlugin.create({
lineColor: local.hoverColor ?? undefined
});
ws.registerPlugin(hoverPlugin);
} else if (!v && hoverPlugin) {
ws.unregisterPlugin(hoverPlugin);
hoverPlugin = null;
}
})(v)), { defer: true }));
createEffect(on(() => (() => regions())(), (v) => untrack(() => ((list: any) => {
// Lazy registration: `regions` transitioned to an array after mount and the
// plugin doesn't exist yet — register it now. If the engine has already
// decoded audio (wsReady), open the reconcile gate immediately; otherwise
// `ready`'s own catch-up (above) opens it once duration is known.
if (Array.isArray(list) && !regionsPlugin && ws) {
ensureRegionsPlugin();
if (wsReady) regionsReady = true;
}
// Controlled reconcile of the live regions to match the incoming list.
// Gated on `regionsReady` (duration known) and value-equality-guarded inside
// reconcileRegions so a writeback echo doesn't loop.
if (!regionsReady) return;
reconcileRegions(list);
})(v)), { defer: true }));
let containerRef: HTMLElement | null = null;
// null-let so the bundled-leaf typeNeutralize pass annotates it `any`: the engine's
// strict WaveSurferOptions/return types don't match the loosely-typed .rozie props,
// and (per the engine-wrapper recipe) `ws` must be TOP-LEVEL — the Solid emitter
// splits $onMount into onMount(...) + onCleanup(...), so a mount-local `let` would
// be out of scope in teardown (TS2304).
let ws: any = null;
// Regions plugin instance + its two guards (all top-level for the Solid teardown
// scope, same reason as `ws`). `regionsReady` gates the reconcile until the audio
// is decoded (addRegion needs a known duration). `reconciling` is the re-entrancy
// guard: while a controlled reconcile mutates the engine, the region-event
// handlers must NOT emit or write back (that would fight the incoming update) —
// only genuine USER edits (outside reconcile) drive the model + emits.
let regionsPlugin: any = null;
let regionsReady = false;
let reconciling = false;
// timelinePlugin / hoverPlugin (live plugin-presence toggling) — top-level so
// the $watch(timeline)/$watch(hover) blocks below can register/unregister them
// on the running engine. wsReady tracks "the engine has decoded audio and
// fired `ready`", independent of whether a regions plugin exists — it gates
// the rare async-window lazy-registration case in the `ready` handler below.
let timelinePlugin: any = null;
let hoverPlugin: any = null;
let wsReady = false;
// Serialize an engine Region to the plain descriptor shape the two-way `regions`
// model carries. Pure (no sigils) — safe at top level.
function serializeRegion(r: any) {
return {
id: r.id,
start: r.start,
end: r.end,
color: r.color,
content: r.content && r.content.textContent ? r.content.textContent : undefined,
drag: r.drag,
resize: r.resize
};
}
// Value-equality guard (by id + rounded start/end) that stops the
// user-edit → writeback → $model.regions → $watch → reconcile loop from
// oscillating (the Cropper `sameData` idiom, generalized to a list).
function sameRegions(list: any, engineRegions: any) {
if (!Array.isArray(list) || list.length !== engineRegions.length) return false;
const key = (r: any) => `${r.id}:${Math.round((r.start ?? 0) * 1000)}:${Math.round((r.end ?? 0) * 1000)}`;
const a = list.map(key).sort();
const b = engineRegions.map(key).sort();
return a.every((k: any, i: any) => k === b[i]);
}
// Push the live engine regions back into the two-way `regions` model (serialized).
// No-op while `reconciling` — a controlled update must not echo back onto itself.
function writeBackRegions() {
if (!regionsPlugin || reconciling) return;
setRegions(regionsPlugin.getRegions().map(serializeRegion));
}
// Reconcile the live engine regions to match a consumer-provided descriptor list:
// update-by-id, add the new, remove the missing. Guarded by `reconciling` so the
// add/remove/setOptions calls don't trigger writeBackRegions mid-flight. If any
// region was added WITHOUT a consumer id, echo the engine state (now carrying the
// assigned ids) back once so the two-way binding gains them.
function reconcileRegions(list: any) {
if (!regionsPlugin || !Array.isArray(list)) return;
const current = regionsPlugin.getRegions();
if (sameRegions(list, current)) return;
reconciling = true;
let addedWithoutId = false;
// Build the id→region map with a no-arg `new Map()` (infers Map<any, any>) — a
// `new Map(current.map(...))` over the `any`-typed engine list infers
// Map<unknown, unknown>, so `.setOptions` would fail the strict leaf typecheck.
const byId = new Map();
for (const r of current as any) byId.set(r.id, r);
const keep = new Set();
for (const desc of list as any) {
if (!desc || typeof desc.start !== 'number') continue;
if (desc.id != null && byId.has(desc.id)) {
byId.get(desc.id).setOptions({
start: desc.start,
end: desc.end,
color: desc.color,
drag: desc.drag,
resize: desc.resize,
content: desc.content
});
keep.add(desc.id);
} else {
const created = regionsPlugin.addRegion({
id: desc.id,
start: desc.start,
end: desc.end,
color: desc.color,
content: desc.content,
drag: desc.drag,
resize: desc.resize
});
keep.add(created.id);
if (desc.id == null) addedWithoutId = true;
}
}
for (const r of current as any) {
if (!keep.has(r.id)) r.remove();
}
reconciling = false;
if (addedWithoutId) writeBackRegions();
}
// Attach the 6 region-event listeners to a live RegionsPlugin instance — shared
// by the construction-time path (buildWaveSurfer) and the lazy path
// (ensureRegionsPlugin) so both register identical behavior through one code
// path. Each writeback/emit is a no-op during a controlled reconcile (the
// `reconciling` guard) so a programmatic add/update/remove does not echo back
// or double-emit; only genuine user gestures (drag-create, drag/resize,
// delete) drive the model + emits.
function wireRegionsPluginEvents(plugin: any) {
plugin.on('region-created', (region: any) => {
if (reconciling) return;
_props.onRegionCreated?.(serializeRegion(region));
writeBackRegions();
});
plugin.on('region-updated', (region: any) => {
if (reconciling) return;
_props.onRegionUpdated?.(serializeRegion(region));
writeBackRegions();
});
plugin.on('region-removed', (region: any) => {
if (reconciling) return;
_props.onRegionRemoved?.(serializeRegion(region));
writeBackRegions();
});
plugin.on('region-clicked', (region: any) => {
_props.onRegionClicked?.(serializeRegion(region));
});
// Playback entered/left a region — pure notifications (no writeback), so they
// fire regardless of the reconcile guard. The events for active-segment
// highlighting, transcript/karaoke sync, and loop-a-region.
plugin.on('region-in', (region: any) => {
_props.onRegionIn?.(serializeRegion(region));
});
plugin.on('region-out', (region: any) => {
_props.onRegionOut?.(serializeRegion(region));
});
}
// Lazily register the Regions plugin on the LIVE engine (idempotent — a no-op
// if it already exists or the engine isn't built yet). Shared by the `ready`
// handler's async-window catch-up and the $watch(regions) transition-to-array
// path, so `regions` flipping from null/undefined to an array after mount
// registers the plugin without a remount.
function ensureRegionsPlugin() {
if (regionsPlugin || !ws) return regionsPlugin;
regionsPlugin = RegionsPlugin.create();
ws.registerPlugin(regionsPlugin);
wireRegionsPluginEvents(regionsPlugin);
if (local.dragToCreateRegions) {
regionsPlugin.enableDragSelection({
color: local.regionColor ?? undefined
});
}
return regionsPlugin;
}
// Build the engine. The whole config object is untyped (ws is `any`) so the
// constructor's options + event-callback params are unchecked against wavesurfer's
// strict types (the Cropper buildCropper idiom).
function buildWaveSurfer() {
let plugins = [];
plugins = [];
if (local.timeline) {
timelinePlugin = TimelinePlugin.create();
plugins.push(timelinePlugin);
}
if (local.hover) {
hoverPlugin = HoverPlugin.create({
lineColor: local.hoverColor ?? undefined
});
plugins.push(hoverPlugin);
}
// Regions plugin is registered when `regions` is an array (even empty).
regionsPlugin = null;
if (Array.isArray(regions())) {
regionsPlugin = RegionsPlugin.create();
plugins.push(regionsPlugin);
}
let cfg: any = null;
cfg = {
...local.options,
container: containerRef,
url: local.src ?? undefined,
height: local.height,
waveColor: local.waveColor,
progressColor: local.progressColor,
cursorColor: local.cursorColor,
cursorWidth: local.cursorWidth,
barWidth: local.barWidth ?? undefined,
barGap: local.barGap ?? undefined,
barRadius: local.barRadius ?? undefined,
minPxPerSec: local.minPxPerSec,
autoplay: local.autoplay,
normalize: local.normalizeAmplitude,
hideScrollbar: local.hideScrollbar,
interact: !local.disableInteraction,
dragToSeek: !local.disableDragToSeek,
plugins: plugins
};
// peaks/duration override the `options` bag ONLY when actually provided —
// assigning `undefined` unconditionally would clobber a caller's options.peaks.
if (local.peaks != null) cfg.peaks = local.peaks;
if (local.duration != null) cfg.duration = local.duration;
ws = WaveSurfer.create(cfg);
// ── engine events → emits + the two-way currentTime writeback ──────────────
ws.on('ready', (duration: any) => {
wsReady = true;
// Rare async-window catch-up: `regions` became an array between mount and
// `ready` firing, before `wsReady` was true, so the $watch(regions) lazy
// path below couldn't gate on it yet. ensureRegionsPlugin is idempotent.
if (Array.isArray(regions())) ensureRegionsPlugin();
// Regions can only be placed once the duration is known — do the initial
// reconcile + drag-selection wiring here, then open the gate for prop-driven
// reconciles. ($watch is lazy, so it never fires at mount; this is the only
// place initial regions get added.)
if (regionsPlugin) {
regionsReady = true;
if (local.dragToCreateRegions) {
regionsPlugin.enableDragSelection({
color: local.regionColor ?? undefined
});
}
reconcileRegions(regions());
}
_props.onReady?.(duration);
});
ws.on('play', () => _props.onPlaying?.());
ws.on('pause', () => _props.onPaused?.());
ws.on('finish', () => _props.onFinished?.());
ws.on('timeupdate', (t: any) => {
// Echo the live position into the two-way model, then emit. The reverse
// $watch below is value-equality-guarded, so this write does not loop.
setCurrentTime(t);
_props.onTimeupdate?.(t);
});
ws.on('seeking', (t: any) => _props.onSeeking?.(t));
ws.on('interaction', (t: any) => _props.onInteraction?.(t));
ws.on('loading', (percent: any) => _props.onLoading?.(percent));
ws.on('error', (err: any) => _props.onError?.(err));
// ── regions plugin events ───────────────────────────────────────────────────
// Shared with the lazy ensureRegionsPlugin() path so construction-time and
// lazy registration wire identical listener behavior through one function.
if (regionsPlugin) wireRegionsPluginEvents(regionsPlugin);
}
// ─── imperative handle (Phase 21 $expose) ────────────────────────────────────
// Collision-clear across all six targets: canonical media verbs play/pause/
// playPause kept (the emits were renamed playing/paused/finished to dodge ROZ121);
// no setCurrentTime (React model auto-setter, ROZ524 — use setTime); no Lit
// reserved lifecycle name (update/render/firstUpdated/updated/willUpdate/requestUpdate).
function play() {
if (ws) ws.play();
}
function pause() {
if (ws) ws.pause();
}
function playPause() {
if (ws) ws.playPause();
}
function stop() {
if (ws) ws.stop();
}
function seekTo(progress: any) {
if (ws) ws.seekTo(progress);
}
function setTime(seconds: any) {
if (ws) ws.setTime(seconds);
}
function setVolume(v: any) {
if (ws) ws.setVolume(v);
}
function setPlaybackRate(rate: any) {
if (ws) ws.setPlaybackRate(rate);
}
function setZoom(pxPerSec: any) {
if (ws) ws.zoom(pxPerSec);
}
function load(url: any) {
if (ws) ws.load(url);
}
function isPlaying() {
return ws ? ws.isPlaying() : false;
}
function getDuration() {
return ws ? ws.getDuration() : 0;
}
function getCurrentTime() {
return ws ? ws.getCurrentTime() : 0;
}
function getWaveSurfer() {
return ws;
}
// Regions imperative surface (active only when the `regions` array registered the
// plugin). `addRegion` returns the created engine Region; NO `setRegions` verb
// (the React `regions`-model auto-setter, ROZ524 — drive the list via the two-way
// binding instead).
function addRegion(opts: any) {
return regionsPlugin ? regionsPlugin.addRegion(opts) : null;
}
function clearRegions() {
if (regionsPlugin) regionsPlugin.clearRegions();
}
function getRegions() {
return regionsPlugin ? regionsPlugin.getRegions() : [];
}
return (
<>
<div ref={(el) => { containerRef = el as HTMLElement; }} {...attrs} class={"rozie-waveform" + (((attrs as unknown as Record<string, unknown>).class as string | undefined) ? " " + ((attrs as unknown as Record<string, unknown>).class as string | undefined) : "")} data-rozie-s-0b6fbb3a="" />
</>
);
}ts
import { LitElement, css, html } from 'lit';
import { customElement, property, query } from 'lit/decorators.js';
import { SignalWatcher, effect, untracked } from '@lit-labs/preact-signals';
import { createLitControllableProperty, rozieListeners, rozieSpread } from '@rozie/runtime-lit';
// Default import is `WaveSurfer` (≠ the component name `Waveform`, so no import⇄
// component collision — Cropper had to alias; we don't). Plugin factories are
// separate v7 entry points.
import WaveSurfer from 'wavesurfer.js';
import TimelinePlugin from 'wavesurfer.js/plugins/timeline';
import HoverPlugin from 'wavesurfer.js/plugins/hover';
import RegionsPlugin from 'wavesurfer.js/plugins/regions';
// null-let so the bundled-leaf typeNeutralize pass annotates it `any`: the engine's
// strict WaveSurferOptions/return types don't match the loosely-typed .rozie props,
// and (per the engine-wrapper recipe) `ws` must be TOP-LEVEL — the Solid emitter
// splits $onMount into onMount(...) + onCleanup(...), so a mount-local `let` would
// be out of scope in teardown (TS2304).
@customElement('rozie-waveform')
export default class Waveform extends SignalWatcher(LitElement) {
static styles = css`
:host{display:contents}
.rozie-waveform[data-rozie-s-0b6fbb3a] {
width: 100%;
}
`;
/**
* The audio URL the waveform loads. Bound at construction and reconciled at runtime — changing it calls the engine `load(url)`.
* @example
* <Waveform :src="audioUrl" r-model:currentTime="time" />
*/
@property({ type: String, reflect: true }) src: string | null = null;
/**
* 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.
*/
@property({ type: Object }) peaks?: unknown;
/**
* 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.
*/
@property({ type: Number, reflect: true }) duration: number | null = null;
/**
* The waveform height in pixels. Reconciled at runtime via `setOptions`.
*/
@property({ type: Number, reflect: true }) height: number = 128;
/**
* The color of the unplayed portion of the waveform. Reconciled at runtime via `setOptions`.
*/
@property({ type: String, reflect: true }) waveColor: string = '#8a2be2';
/**
* The color of the played (progress) portion of the waveform. Reconciled at runtime via `setOptions`.
*/
@property({ type: String, reflect: true }) progressColor: string = '#5a189a';
/**
* The color of the playback cursor. Reconciled at runtime via `setOptions`.
*/
@property({ type: String, reflect: true }) cursorColor: string = '#333333';
/**
* The width of the playback cursor in pixels. Reconciled at runtime via `setOptions`.
*/
@property({ type: Number, reflect: true }) cursorWidth: number = 1;
/**
* Draw the waveform as bars of this pixel width. `null` (default) renders a continuous waveform. Reconciled at runtime via `setOptions`.
*/
@property({ type: Object }) barWidth: unknown = null;
/**
* The pixel gap between bars (when `barWidth` is set). Reconciled at runtime via `setOptions`.
*/
@property({ type: Object }) barGap: unknown = null;
/**
* The corner radius of bars (when `barWidth` is set). Reconciled at runtime via `setOptions`.
*/
@property({ type: Object }) barRadius: unknown = null;
/**
* The minimum pixels-per-second zoom level. Reconciled at runtime via `zoom`.
*/
@property({ type: Number, reflect: true }) minPxPerSec: number = 1;
/**
* Playback volume (`0`–`1`). Reconciled at runtime via `setVolume`.
*/
@property({ type: Number, reflect: true }) volume: number = 1;
/**
* Playback speed multiplier. Reconciled at runtime via `setPlaybackRate`.
*/
@property({ type: Number, reflect: true }) playbackRate: number = 1;
/**
* Begin playback as soon as the audio is ready. Construction-only.
*/
@property({ type: Boolean, reflect: true }) autoplay: boolean = false;
/**
* Normalize the waveform by its largest peak (wavesurfer's `normalize` option). Reconciled at runtime via `setOptions`.
*/
@property({ type: Boolean, reflect: true }) normalizeAmplitude: boolean = false;
/**
* Hide the horizontal scrollbar when the waveform is zoomed wider than its container. Construction-only.
*/
@property({ type: Boolean, reflect: true }) hideScrollbar: boolean = false;
/**
* Disable click/seek interaction with the waveform (the engine defaults to interactive). Construction-only.
*/
@property({ type: Boolean, reflect: true }) disableInteraction: boolean = false;
/**
* Disable drag-to-seek across the waveform (the engine defaults to drag-seekable). Construction-only.
*/
@property({ type: Boolean, reflect: true }) disableDragToSeek: boolean = false;
/**
* Render a time-ruler beneath the waveform (the wavesurfer Timeline plugin). Live-toggleable — registers/unregisters on the running engine, no remount.
*/
@property({ type: Boolean, reflect: true }) timeline: boolean = false;
/**
* Show a hover cursor with a time label as the pointer moves over the waveform (the wavesurfer Hover plugin). Live-toggleable — registers/unregisters on the running engine, no remount.
*/
@property({ type: Boolean, reflect: true }) hover: boolean = false;
/**
* The line color of the Hover plugin cursor (only applies when `hover` is enabled). Read/applied when the Hover plugin is (re-)created — not live on an already-registered instance.
*/
@property({ type: String, reflect: true }) hoverColor: string | null = null;
/**
* The interactive regions as an array of `{ id?, start, end?, content?, color?, drag?, resize? }`. Providing an array (even empty) registers the Regions plugin — at construction if it's already an array, or lazily the first time `regions` transitions from `null`/`undefined` to an array. Two-way (`model: true`): user create / drag / resize / remove writes the updated array back (round-trip-guarded); a consumer write reconciles the live regions (add / update / remove by `id`).
*/
@property({ type: Object, attribute: 'regions' }) _regions_attr?: unknown;
private _regionsControllable = createLitControllableProperty<unknown>({ host: this, eventName: 'regions-change', defaultValue: undefined, initialControlledValue: undefined });
/**
* Allow drawing new regions by dragging over empty waveform space (Regions plugin `enableDragSelection`). Requires `regions` to be an array. Read/applied when the Regions plugin is (re-)created — not live on an already-registered instance.
*/
@property({ type: Boolean, reflect: true }) dragToCreateRegions: boolean = false;
/**
* Default fill color for drag-created regions (only applies when `dragToCreateRegions` is on). Read/applied when the Regions plugin is (re-)created — not live on an already-registered instance.
*/
@property({ type: String, reflect: true }) regionColor: string | null = null;
/**
* 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`, …).
*/
@property({ type: Object }) options: any = {};
/**
* The current playback position in seconds. The lone two-way `model: true` prop: playback writes the live position back on every `timeupdate` (round-trip-guarded so a programmatic write does not ping-pong), and a consumer write seeks the engine via `setTime`.
*/
@property({ type: Object, attribute: 'current-time' }) _currentTime_attr?: unknown;
private _currentTimeControllable = createLitControllableProperty<unknown>({ host: this, eventName: 'current-time-change', defaultValue: undefined, initialControlledValue: undefined });
@query('[data-rozie-ref="container"]') private _refContainer!: HTMLElement;
private __rozieWatchInitial_13 = true;
private __rozieWatchInitial_16 = true;
private __rozieFirstUpdateDone = false;
private _disconnectCleanups: Array<() => void> = [];
// Re-parenting guard: set true once the deferred teardown has actually
// run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
private _rozieTornDown = false;
firstUpdated(): void {
this._disconnectCleanups.push((() => {
if (this.ws) this.ws.destroy();
}));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this.currentTime)(); untracked(() => { if (this.__rozieWatchInitial_13) { this.__rozieWatchInitial_13 = false; return; } ((v: any) => {
// Round-trip guard: skip if the incoming value already matches the engine
// position (the timeupdate → $model → $watch echo), else seek.
if (!this.ws || typeof v !== 'number') return;
if (Math.abs(v - this.ws.getCurrentTime()) < 0.05) return;
this.ws.setTime(v);
})(__watchVal); }); }));
this._disconnectCleanups.push(effect(() => { const __watchVal = (() => this.regions)(); untracked(() => { if (this.__rozieWatchInitial_16) { this.__rozieWatchInitial_16 = false; return; } ((list: any) => {
// Lazy registration: `regions` transitioned to an array after mount and the
// plugin doesn't exist yet — register it now. If the engine has already
// decoded audio (wsReady), open the reconcile gate immediately; otherwise
// `ready`'s own catch-up (above) opens it once duration is known.
if (Array.isArray(list) && !this.regionsPlugin && this.ws) {
this.ensureRegionsPlugin();
if (this.wsReady) this.regionsReady = true;
}
// Controlled reconcile of the live regions to match the incoming list.
// Gated on `regionsReady` (duration known) and value-equality-guarded inside
// reconcileRegions so a writeback echo doesn't loop.
if (!this.regionsReady) return;
this.reconcileRegions(list);
})(__watchVal); }); }));
// $refs read ONLY here (ROZ123). The container is the engine's attach target.
this.buildWaveSurfer();
}
updated(changedProperties: Map<string, unknown>): void {
if (this.__rozieFirstUpdateDone && (changedProperties.has('src'))) { const __watchVal = (() => this.src)(); ((v: any) => {
if (this.ws && typeof v === 'string' && v) this.ws.load(v);
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('height'))) { const __watchVal = (() => this.height)(); ((v: any) => {
if (this.ws) this.ws.setOptions({
height: v
});
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('waveColor'))) { const __watchVal = (() => this.waveColor)(); ((v: any) => {
if (this.ws) this.ws.setOptions({
waveColor: v
});
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('progressColor'))) { const __watchVal = (() => this.progressColor)(); ((v: any) => {
if (this.ws) this.ws.setOptions({
progressColor: v
});
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('cursorColor'))) { const __watchVal = (() => this.cursorColor)(); ((v: any) => {
if (this.ws) this.ws.setOptions({
cursorColor: v
});
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('cursorWidth'))) { const __watchVal = (() => this.cursorWidth)(); ((v: any) => {
if (this.ws) this.ws.setOptions({
cursorWidth: v
});
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('barWidth'))) { const __watchVal = (() => this.barWidth)(); ((v: any) => {
if (this.ws) this.ws.setOptions({
barWidth: v ?? undefined
});
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('barGap'))) { const __watchVal = (() => this.barGap)(); ((v: any) => {
if (this.ws) this.ws.setOptions({
barGap: v ?? undefined
});
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('barRadius'))) { const __watchVal = (() => this.barRadius)(); ((v: any) => {
if (this.ws) this.ws.setOptions({
barRadius: v ?? undefined
});
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('normalizeAmplitude'))) { const __watchVal = (() => this.normalizeAmplitude)(); ((v: any) => {
if (this.ws) this.ws.setOptions({
normalize: v
});
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('volume'))) { const __watchVal = (() => this.volume)(); ((v: any) => {
if (this.ws && typeof v === 'number') this.ws.setVolume(v);
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('playbackRate'))) { const __watchVal = (() => this.playbackRate)(); ((v: any) => {
if (this.ws && typeof v === 'number') this.ws.setPlaybackRate(v);
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('minPxPerSec'))) { const __watchVal = (() => this.minPxPerSec)(); ((v: any) => {
if (this.ws && typeof v === 'number' && v > 0) this.ws.zoom(v);
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('timeline'))) { const __watchVal = (() => this.timeline)(); ((v: any) => {
if (!this.ws) return;
if (v && !this.timelinePlugin) {
this.timelinePlugin = TimelinePlugin.create();
this.ws.registerPlugin(this.timelinePlugin);
} else if (!v && this.timelinePlugin) {
this.ws.unregisterPlugin(this.timelinePlugin);
this.timelinePlugin = null;
}
})(__watchVal); }
if (this.__rozieFirstUpdateDone && (changedProperties.has('hover'))) { const __watchVal = (() => this.hover)(); ((v: any) => {
if (!this.ws) return;
if (v && !this.hoverPlugin) {
this.hoverPlugin = HoverPlugin.create({
lineColor: this.hoverColor ?? undefined
});
this.ws.registerPlugin(this.hoverPlugin);
} else if (!v && this.hoverPlugin) {
this.ws.unregisterPlugin(this.hoverPlugin);
this.hoverPlugin = null;
}
})(__watchVal); }
this.__rozieFirstUpdateDone = true;
}
disconnectedCallback(): void {
super.disconnectedCallback();
queueMicrotask(() => {
if (this.isConnected || this._rozieTornDown) return;
this._rozieTornDown = true;
for (const fn of this._disconnectCleanups) fn();
this._disconnectCleanups = [];
});
}
attributeChangedCallback(name: string, old: string | null, value: string | null): void {
super.attributeChangedCallback(name, old, value);
if (name === 'regions') this._regionsControllable.notifyAttributeChange(value as unknown as unknown);
if (name === 'current-time') this._currentTimeControllable.notifyAttributeChange(value as unknown as unknown);
}
render() {
return html`
<div class="rozie-waveform" ${rozieSpread(this.$attrs)} ${rozieListeners(this.$listeners)} data-rozie-ref="container" data-rozie-s-0b6fbb3a></div>
`;
}
ws: any = null;
regionsPlugin: any = null;
regionsReady = false;
reconciling = false;
timelinePlugin: any = null;
hoverPlugin: any = null;
wsReady = false;
serializeRegion = (r: any) => ({
id: r.id,
start: r.start,
end: r.end,
color: r.color,
content: r.content && r.content.textContent ? r.content.textContent : undefined,
drag: r.drag,
resize: r.resize
});
sameRegions = (list: any, engineRegions: any) => {
if (!Array.isArray(list) || list.length !== engineRegions.length) return false;
const key = (r: any) => `${r.id}:${Math.round((r.start ?? 0) * 1000)}:${Math.round((r.end ?? 0) * 1000)}`;
const a = list.map(key).sort();
const b = engineRegions.map(key).sort();
return a.every((k: any, i: any) => k === b[i]);
};
writeBackRegions = () => {
if (!this.regionsPlugin || this.reconciling) return;
this._regionsControllable.write(this.regionsPlugin.getRegions().map(this.serializeRegion));
};
reconcileRegions = (list: any) => {
if (!this.regionsPlugin || !Array.isArray(list)) return;
const current = this.regionsPlugin.getRegions();
if (this.sameRegions(list, current)) return;
this.reconciling = true;
let addedWithoutId = false;
// Build the id→region map with a no-arg `new Map()` (infers Map<any, any>) — a
// `new Map(current.map(...))` over the `any`-typed engine list infers
// Map<unknown, unknown>, so `.setOptions` would fail the strict leaf typecheck.
const byId = new Map();
for (const r of current as any) byId.set(r.id, r);
const keep = new Set();
for (const desc of list as any) {
if (!desc || typeof desc.start !== 'number') continue;
if (desc.id != null && byId.has(desc.id)) {
byId.get(desc.id).setOptions({
start: desc.start,
end: desc.end,
color: desc.color,
drag: desc.drag,
resize: desc.resize,
content: desc.content
});
keep.add(desc.id);
} else {
const created = this.regionsPlugin.addRegion({
id: desc.id,
start: desc.start,
end: desc.end,
color: desc.color,
content: desc.content,
drag: desc.drag,
resize: desc.resize
});
keep.add(created.id);
if (desc.id == null) addedWithoutId = true;
}
}
for (const r of current as any) {
if (!keep.has(r.id)) r.remove();
}
this.reconciling = false;
if (addedWithoutId) this.writeBackRegions();
};
wireRegionsPluginEvents = (plugin: any) => {
plugin.on('region-created', (region: any) => {
if (this.reconciling) return;
this.dispatchEvent(new CustomEvent("regionCreated", {
detail: this.serializeRegion(region),
bubbles: true,
composed: true
}));
this.writeBackRegions();
});
plugin.on('region-updated', (region: any) => {
if (this.reconciling) return;
this.dispatchEvent(new CustomEvent("regionUpdated", {
detail: this.serializeRegion(region),
bubbles: true,
composed: true
}));
this.writeBackRegions();
});
plugin.on('region-removed', (region: any) => {
if (this.reconciling) return;
this.dispatchEvent(new CustomEvent("regionRemoved", {
detail: this.serializeRegion(region),
bubbles: true,
composed: true
}));
this.writeBackRegions();
});
plugin.on('region-clicked', (region: any) => {
this.dispatchEvent(new CustomEvent("regionClicked", {
detail: this.serializeRegion(region),
bubbles: true,
composed: true
}));
});
// Playback entered/left a region — pure notifications (no writeback), so they
// fire regardless of the reconcile guard. The events for active-segment
// highlighting, transcript/karaoke sync, and loop-a-region.
plugin.on('region-in', (region: any) => {
this.dispatchEvent(new CustomEvent("regionIn", {
detail: this.serializeRegion(region),
bubbles: true,
composed: true
}));
});
plugin.on('region-out', (region: any) => {
this.dispatchEvent(new CustomEvent("regionOut", {
detail: this.serializeRegion(region),
bubbles: true,
composed: true
}));
});
};
ensureRegionsPlugin = () => {
if (this.regionsPlugin || !this.ws) return this.regionsPlugin;
this.regionsPlugin = RegionsPlugin.create();
this.ws.registerPlugin(this.regionsPlugin);
this.wireRegionsPluginEvents(this.regionsPlugin);
if (this.dragToCreateRegions) {
this.regionsPlugin.enableDragSelection({
color: this.regionColor ?? undefined
});
}
return this.regionsPlugin;
};
buildWaveSurfer = () => {
let plugins = [];
plugins = [];
if (this.timeline) {
this.timelinePlugin = TimelinePlugin.create();
plugins.push(this.timelinePlugin);
}
if (this.hover) {
this.hoverPlugin = HoverPlugin.create({
lineColor: this.hoverColor ?? undefined
});
plugins.push(this.hoverPlugin);
}
// Regions plugin is registered when `regions` is an array (even empty).
this.regionsPlugin = null;
if (Array.isArray(this.regions)) {
this.regionsPlugin = RegionsPlugin.create();
plugins.push(this.regionsPlugin);
}
let cfg: any = null;
cfg = {
...this.options,
container: this._refContainer,
url: this.src ?? undefined,
height: this.height,
waveColor: this.waveColor,
progressColor: this.progressColor,
cursorColor: this.cursorColor,
cursorWidth: this.cursorWidth,
barWidth: this.barWidth ?? undefined,
barGap: this.barGap ?? undefined,
barRadius: this.barRadius ?? undefined,
minPxPerSec: this.minPxPerSec,
autoplay: this.autoplay,
normalize: this.normalizeAmplitude,
hideScrollbar: this.hideScrollbar,
interact: !this.disableInteraction,
dragToSeek: !this.disableDragToSeek,
plugins: plugins
};
// peaks/duration override the `options` bag ONLY when actually provided —
// assigning `undefined` unconditionally would clobber a caller's options.peaks.
if (this.peaks != null) cfg.peaks = this.peaks;
if (this.duration != null) cfg.duration = this.duration;
this.ws = WaveSurfer.create(cfg);
// ── engine events → emits + the two-way currentTime writeback ──────────────
this.ws.on('ready', (duration: any) => {
this.wsReady = true;
// Rare async-window catch-up: `regions` became an array between mount and
// `ready` firing, before `wsReady` was true, so the $watch(regions) lazy
// path below couldn't gate on it yet. ensureRegionsPlugin is idempotent.
if (Array.isArray(this.regions)) this.ensureRegionsPlugin();
// Regions can only be placed once the duration is known — do the initial
// reconcile + drag-selection wiring here, then open the gate for prop-driven
// reconciles. ($watch is lazy, so it never fires at mount; this is the only
// place initial regions get added.)
if (this.regionsPlugin) {
this.regionsReady = true;
if (this.dragToCreateRegions) {
this.regionsPlugin.enableDragSelection({
color: this.regionColor ?? undefined
});
}
this.reconcileRegions(this.regions);
}
this.dispatchEvent(new CustomEvent("ready", {
detail: duration,
bubbles: true,
composed: true
}));
});
this.ws.on('play', () => this.dispatchEvent(new CustomEvent("playing", {
detail: undefined,
bubbles: true,
composed: true
})));
this.ws.on('pause', () => this.dispatchEvent(new CustomEvent("paused", {
detail: undefined,
bubbles: true,
composed: true
})));
this.ws.on('finish', () => this.dispatchEvent(new CustomEvent("finished", {
detail: undefined,
bubbles: true,
composed: true
})));
this.ws.on('timeupdate', (t: any) => {
// Echo the live position into the two-way model, then emit. The reverse
// $watch below is value-equality-guarded, so this write does not loop.
this._currentTimeControllable.write(t);
this.dispatchEvent(new CustomEvent("timeupdate", {
detail: t,
bubbles: true,
composed: true
}));
});
this.ws.on('seeking', (t: any) => this.dispatchEvent(new CustomEvent("seeking", {
detail: t,
bubbles: true,
composed: true
})));
this.ws.on('interaction', (t: any) => this.dispatchEvent(new CustomEvent("interaction", {
detail: t,
bubbles: true,
composed: true
})));
this.ws.on('loading', (percent: any) => this.dispatchEvent(new CustomEvent("loading", {
detail: percent,
bubbles: true,
composed: true
})));
this.ws.on('error', (err: any) => this.dispatchEvent(new CustomEvent("error", {
detail: err,
bubbles: true,
composed: true
})));
// ── regions plugin events ───────────────────────────────────────────────────
// Shared with the lazy ensureRegionsPlugin() path so construction-time and
// lazy registration wire identical listener behavior through one function.
if (this.regionsPlugin) this.wireRegionsPluginEvents(this.regionsPlugin);
};
play() {
if (this.ws) this.ws.play();
}
pause() {
if (this.ws) this.ws.pause();
}
playPause() {
if (this.ws) this.ws.playPause();
}
stop() {
if (this.ws) this.ws.stop();
}
seekTo(progress: any) {
if (this.ws) this.ws.seekTo(progress);
}
setTime(seconds: any) {
if (this.ws) this.ws.setTime(seconds);
}
setVolume(v: any) {
if (this.ws) this.ws.setVolume(v);
}
setPlaybackRate(rate: any) {
if (this.ws) this.ws.setPlaybackRate(rate);
}
setZoom(pxPerSec: any) {
if (this.ws) this.ws.zoom(pxPerSec);
}
load(url: any) {
if (this.ws) this.ws.load(url);
}
isPlaying() {
return this.ws ? this.ws.isPlaying() : false;
}
getDuration() {
return this.ws ? this.ws.getDuration() : 0;
}
getCurrentTime() {
return this.ws ? this.ws.getCurrentTime() : 0;
}
getWaveSurfer() {
return this.ws;
}
addRegion(opts: any) {
return this.regionsPlugin ? this.regionsPlugin.addRegion(opts) : null;
}
clearRegions() {
if (this.regionsPlugin) this.regionsPlugin.clearRegions();
}
getRegions() {
return this.regionsPlugin ? this.regionsPlugin.getRegions() : [];
}
get regions(): unknown { return this._regionsControllable.read(); }
set regions(v: unknown) { this._regionsControllable.notifyPropertyWrite(v); }
get currentTime(): unknown { return this._currentTimeControllable.read(); }
set currentTime(v: unknown) { this._currentTimeControllable.notifyPropertyWrite(v); }
/**
* Plan 14-05 — cross-framework attribute fallthrough source. Reads the
* host custom element's attributes on each call so a consumer-side bound
* attribute flows through on every render. The `rozieSpread` directive
* (D-02) does the cross-render diff downstream.
*
* Phase 15 follow-up Bug A — declared-prop attribute names are filtered
* out so `$attrs` returns "rest after declared props" (semantic parity
* with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
* forms are folded into the skip set: kebab-case for model props
* (explicit `attribute:`) AND lowercased property name (Lit's default).
*
* command-palette-per-level-virtual / portal-through-portal cluster —
* `data-rozie-ref` is ALWAYS skipped too (a reserved compiler bookkeeping
* attribute, never a consumer prop) so a parent-assigned `ref=` on this
* component's own host tag can never clobber this component's OWN
* internal `data-rozie-ref` ref markers via fallthrough re-application.
*/
private get $attrs(): Record<string, string> {
const __skip = new Set<string>(['data-rozie-ref', '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']);
const out: Record<string, string> = {};
for (const a of Array.from(this.attributes)) {
if (__skip.has(a.name)) continue;
out[a.name] = a.value;
}
return out;
}
/**
* Phase 15 D-19 — consumer-passed listener cluster placeholder.
* Lit attaches event listeners directly on the host element via
* `addEventListener` (no per-instance prop rest binding), so the
* runtime value is undefined; the `rozieListeners` directive's
* nullish coercion (`obj ?? {}`) handles the no-op cleanly.
* The declaration exists to satisfy `tsc --noEmit` on consumer
* projects with strict mode — bare `$listeners` in `render()`
* would otherwise raise TS2304 (Cannot find name).
*/
private get $listeners(): Record<string, EventListener> | undefined {
return undefined;
}
}Each is a real, idiomatic component for its framework — React forwardRef + hooks, Vue <script setup> + defineModel, Svelte 5 runes, an Angular standalone component, a Solid component, and a Lit custom element. Same props, same events, same imperative handle, all from the one source above.
See also
- Waveform — showcase & API — install, quick starts for all six frameworks, and the full reference.
- wavesurfer libraries comparison — how
@rozie-ui/wavesurferstacks up against the per-framework wrappers.