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. The same Waveform component, with the same API, ships for React, Vue, Svelte, Angular, Solid, and Lit.

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.

What ships for each framework ​

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 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 extends Omit<import('react').ComponentPropsWithoutRef<'div'>, 'src' | 'peaks' | 'duration' | 'height' | 'waveColor' | 'progressColor' | 'cursorColor' | 'cursorWidth' | 'barWidth' | 'barGap' | 'barRadius' | 'minPxPerSec' | 'volume' | 'playbackRate' | 'autoplay' | 'normalizeAmplitude' | 'hideScrollbar' | 'disableInteraction' | 'disableDragToSeek' | 'timeline' | 'hover' | 'hoverColor' | 'regions' | 'defaultRegions' | 'onRegionsChange' | 'dragToCreateRegions' | 'regionColor' | 'options' | 'currentTime' | 'defaultCurrentTime' | 'onCurrentTimeChange' | 'onRegionCreated' | 'onRegionUpdated' | 'onRegionRemoved' | 'onRegionClicked' | 'onRegionIn' | 'onRegionOut' | 'onReady' | 'onPlaying' | 'onPaused' | 'onFinished' | 'onTimeupdate' | 'onSeeking' | 'onInteraction' | 'onLoading' | 'onError' | 'children' | 'dangerouslySetInnerHTML'> {
  /**
   * The audio URL the waveform loads. Bound at construction and reconciled at runtime — changing it calls the engine `load(url)`.
   * @example
   * <Waveform src={audioUrl} currentTime={time} onCurrentTimeChange={setTime} />
   */
  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);

  // 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.
  // 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.
  // 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).

  // 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.current || reconciling.current) return;
    setRegions(regionsPlugin.current.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.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();
  }

  // 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.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));
    });
  }

  // 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.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;
  }

  // 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 { 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).
  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" v-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);
}, { flush: 'post' });
watch(() => props.height, (v: any) => {
  if (ws) ws.setOptions({
    height: v
  });
}, { flush: 'post' });
watch(() => props.waveColor, (v: any) => {
  if (ws) ws.setOptions({
    waveColor: v
  });
}, { flush: 'post' });
watch(() => props.progressColor, (v: any) => {
  if (ws) ws.setOptions({
    progressColor: v
  });
}, { flush: 'post' });
watch(() => props.cursorColor, (v: any) => {
  if (ws) ws.setOptions({
    cursorColor: v
  });
}, { flush: 'post' });
watch(() => props.cursorWidth, (v: any) => {
  if (ws) ws.setOptions({
    cursorWidth: v
  });
}, { flush: 'post' });
watch(() => props.barWidth, (v: any) => {
  if (ws) ws.setOptions({
    barWidth: v ?? undefined
  });
}, { flush: 'post' });
watch(() => props.barGap, (v: any) => {
  if (ws) ws.setOptions({
    barGap: v ?? undefined
  });
}, { flush: 'post' });
watch(() => props.barRadius, (v: any) => {
  if (ws) ws.setOptions({
    barRadius: v ?? undefined
  });
}, { flush: 'post' });
watch(() => props.normalizeAmplitude, (v: any) => {
  if (ws) ws.setOptions({
    normalize: v
  });
}, { flush: 'post' });
watch(() => props.volume, (v: any) => {
  if (ws && typeof v === 'number') ws.setVolume(v);
}, { flush: 'post' });
watch(() => props.playbackRate, (v: any) => {
  if (ws && typeof v === 'number') ws.setPlaybackRate(v);
}, { flush: 'post' });
watch(() => props.minPxPerSec, (v: any) => {
  if (ws && typeof v === 'number' && v > 0) ws.zoom(v);
}, { flush: 'post' });
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);
}, { flush: 'post' });
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;
  }
}, { flush: 'post' });
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;
  }
}, { flush: 'post' });
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);
}, { flush: 'post' });

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 extends Omit<import('svelte/elements').SvelteHTMLElements['div'], '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' | 'onregioncreated' | 'onregionupdated' | 'onregionremoved' | 'onregionclicked' | 'onregionin' | 'onregionout' | 'onready' | 'onplaying' | 'onpaused' | 'onfinished' | 'ontimeupdate' | 'onseeking' | 'oninteraction' | 'onloading' | 'onerror' | 'children'> {
  /**
   * The audio URL the waveform loads. Bound at construction and reconciled at runtime — changing it calls the engine `load(url)`.
   * @example
   * <Waveform src={audioUrl} bind: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: 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;
}

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';
import { createRozieAttrApplier, createRozieHostAttrsReader } from '@rozie/runtime-angular';

// 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
   * <rozie-waveform [src]="audioUrl" [(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;
  // 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.
  regionsPlugin: any = null;
  regionsReady = false;
  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.
  timelinePlugin: any = null;
  hoverPlugin: any = null;
  wsReady = false;
  // Serialize an engine Region to the plain descriptor shape the two-way `regions`
  // model carries. Pure (no sigils) — safe at top level.
  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).
  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.
  writeBackRegions = () => {
    if (!this.regionsPlugin || this.reconciling) return;
    this.regions.set(this.regionsPlugin.getRegions().map(this.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.
  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();
  };
  // 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.
  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));
    });
  };
  // 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.
  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;
  };
  // 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).
  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);
  };
  // ─── 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).
  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;
  };
  // 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).
  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 = createRozieAttrApplier(inject(Renderer2));

  private __rozieGetHostAttrs = createRozieHostAttrsReader(inject(ElementRef));

  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 extends Omit<import('solid-js').ComponentProps<'div'>, 'src' | 'peaks' | 'duration' | 'height' | 'waveColor' | 'progressColor' | 'cursorColor' | 'cursorWidth' | 'barWidth' | 'barGap' | 'barRadius' | 'minPxPerSec' | 'volume' | 'playbackRate' | 'autoplay' | 'normalizeAmplitude' | 'hideScrollbar' | 'disableInteraction' | 'disableDragToSeek' | 'timeline' | 'hover' | 'hoverColor' | 'regions' | 'defaultRegions' | 'onRegionsChange' | 'dragToCreateRegions' | 'regionColor' | 'options' | 'currentTime' | 'defaultCurrentTime' | 'onCurrentTimeChange' | 'onRegionCreated' | 'onRegionUpdated' | 'onRegionRemoved' | 'onRegionClicked' | 'onRegionIn' | 'onRegionOut' | 'onReady' | 'onPlaying' | 'onPaused' | 'onFinished' | 'onTimeupdate' | 'onSeeking' | 'onInteraction' | 'onLoading' | 'onError' | 'ref' | 'children' | 'innerHTML' | 'innerText' | 'textContent'> {
  /**
   * The audio URL the waveform loads. Bound at construction and reconciled at runtime — changing it calls the engine `load(url)`.
   * @example
   * <Waveform src={audioUrl} currentTime={time()} onCurrentTimeChange={setTime} />
   */
  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;
  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
   * <rozie-waveform .src=${audioUrl} .currentTime=${time} @current-time-change=${…}></rozie-waveform>
   */
  @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, attribute: 'wave-color' }) waveColor: string = '#8a2be2';
  /**
   * The color of the played (progress) portion of the waveform. Reconciled at runtime via `setOptions`.
   */
  @property({ type: String, reflect: true, attribute: 'progress-color' }) progressColor: string = '#5a189a';
  /**
   * The color of the playback cursor. Reconciled at runtime via `setOptions`.
   */
  @property({ type: String, reflect: true, attribute: 'cursor-color' }) cursorColor: string = '#333333';
  /**
   * The width of the playback cursor in pixels. Reconciled at runtime via `setOptions`.
   */
  @property({ type: Number, reflect: true, attribute: 'cursor-width' }) 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, attribute: 'bar-width' }) barWidth: unknown = null;
  /**
   * The pixel gap between bars (when `barWidth` is set). Reconciled at runtime via `setOptions`.
   */
  @property({ type: Object, attribute: 'bar-gap' }) barGap: unknown = null;
  /**
   * The corner radius of bars (when `barWidth` is set). Reconciled at runtime via `setOptions`.
   */
  @property({ type: Object, attribute: 'bar-radius' }) barRadius: unknown = null;
  /**
   * The minimum pixels-per-second zoom level. Reconciled at runtime via `zoom`.
   */
  @property({ type: Number, reflect: true, attribute: 'min-px-per-sec' }) 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, attribute: 'playback-rate' }) 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, attribute: 'normalize-amplitude' }) normalizeAmplitude: boolean = false;
  /**
   * Hide the horizontal scrollbar when the waveform is zoomed wider than its container. Construction-only.
   */
  @property({ type: Boolean, reflect: true, attribute: 'hide-scrollbar' }) hideScrollbar: boolean = false;
  /**
   * Disable click/seek interaction with the waveform (the engine defaults to interactive). Construction-only.
   */
  @property({ type: Boolean, reflect: true, attribute: 'disable-interaction' }) disableInteraction: boolean = false;
  /**
   * Disable drag-to-seek across the waveform (the engine defaults to drag-seekable). Construction-only.
   */
  @property({ type: Boolean, reflect: true, attribute: 'disable-drag-to-seek' }) 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, attribute: 'hover-color' }) 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, attribute: 'drag-to-create-regions' }) 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, attribute: 'region-color' }) 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;

  // 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.
  regionsPlugin: any = null;

  regionsReady = false;

  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.
  timelinePlugin: any = null;

  hoverPlugin: any = null;

  wsReady = false;

  // Serialize an engine Region to the plain descriptor shape the two-way `regions`
  // model carries. Pure (no sigils) — safe at top level.
  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).
  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.
  writeBackRegions = () => {
  if (!this.regionsPlugin || this.reconciling) return;
  this._regionsControllable.write(this.regionsPlugin.getRegions().map(this.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.
  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();
};

  // 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.
  wireRegionsPluginEvents = (plugin: any) => {
  plugin.on('region-created', (region: any) => {
    if (this.reconciling) return;
    this.dispatchEvent(new CustomEvent("region-created", {
      detail: this.serializeRegion(region),
      bubbles: true,
      composed: true
    }));
    this.writeBackRegions();
  });
  plugin.on('region-updated', (region: any) => {
    if (this.reconciling) return;
    this.dispatchEvent(new CustomEvent("region-updated", {
      detail: this.serializeRegion(region),
      bubbles: true,
      composed: true
    }));
    this.writeBackRegions();
  });
  plugin.on('region-removed', (region: any) => {
    if (this.reconciling) return;
    this.dispatchEvent(new CustomEvent("region-removed", {
      detail: this.serializeRegion(region),
      bubbles: true,
      composed: true
    }));
    this.writeBackRegions();
  });
  plugin.on('region-clicked', (region: any) => {
    this.dispatchEvent(new CustomEvent("region-clicked", {
      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("region-in", {
      detail: this.serializeRegion(region),
      bubbles: true,
      composed: true
    }));
  });
  plugin.on('region-out', (region: any) => {
    this.dispatchEvent(new CustomEvent("region-out", {
      detail: this.serializeRegion(region),
      bubbles: true,
      composed: true
    }));
  });
};

  // 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.
  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;
};

  // 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).
  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);
};

  // ─── 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).
  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;
  }

  // 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).
  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 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, identical on every target.

See also ​

Pre-1.0 — APIs may change between minor versions.