Skip to content

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

Pre-v1.0 — internal monorepo.