{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-delay-tap-display",
  "type": "registry:ui",
  "title": "Delay Tap Display",
  "description": "A delay's echo train on a time axis: a muted reference pip for the dry hit, then one pip per repeat at the moment it arrives and the height it arrives with, fading as well as shrinking so the tail stays readable. Two lanes turn it into a ping-pong view — left grows up from the midline, right grows down, and the first repeat's side comes from the shipped graph rather than from the display. Supply a tempo and it draws the beat grid the repeats are landing on, or missing.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/delay-tap-display.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/delay-tap-display\n * Copyright © [Maru](https://maru.audio)\n *\n * Maru UI Kit — https://maru.audio/ui-kit\n *\n * Installed from the Maru UI registry. This file is yours now: edit it\n * freely, it will not be overwritten unless you re-run the add command.\n *\n * Keep this item’s companion files and CSS imports together. Dependencies\n * declared by the registry are installed with the source.\n */\n/**\n * DelayTapDisplay — a delay's echo train, drawn on a time axis.\n *\n * A delay has no transfer curve. What it does is put the same sound somewhere\n * else in time, quieter, again and again — so the honest picture is the train\n * itself: a pip at the dry hit, then one per repeat, each at the moment it\n * arrives and at the height it arrives with. Two knobs that read as abstract\n * numbers (\"time 0.25, feedback 0.6\") become a rhythm and a decay you can count.\n *\n * Computed from the parameters rather than measured, like the kit's other\n * parameter-side displays: no analyser, no AudioContext, correct the moment the\n * page paints, and it renders on the server. SVG rather than canvas because the\n * picture only changes when a knob does.\n *\n * Two readings of one train, per the catalog's time-based family\n * (`todo/modules/07-ui-surface-catalog.md`):\n *\n *   - **`lanes={1}`** — the mono delay. Pips stand on the baseline and grow up.\n *   - **`lanes={2}`** — the ping-pong. The baseline becomes a midline, the left\n *     channel's pips grow up from it and the right channel's grow down, so the\n *     bounce reads as a zigzag without a zigzag being drawn. Which side the\n *     first repeat lands on is not a display choice: the shipped graph seeds\n *     only its left delay line, and `lib/echo-train` derives the alternation\n *     from that wiring (and pins it against a simulation of it).\n *\n * Wet and dry are told apart by colour AND by opacity, which is the catalog\n * row's \"wet/dry as opacity\" plus the thing that makes it legible. Height\n * already carries level, and by the fifth repeat a pip is a couple of units\n * tall; fading it as well as shrinking it is a redundant encoding on purpose,\n * because a redundant encoding of a two-pixel mark is the difference between\n * seeing a tail and not. The dry hit is drawn muted and hollow-capped as a\n * *reference*: it is not part of the train, and under the stage's crossfade its\n * own height is 1 − mix, so it shrinks as the repeats come up.\n *\n * Supplied a `tempo`, the plot also draws a beat grid. Nothing about it syncs\n * anything — neither shipped delay stage has a tempo sync, and no face passes a\n * tempo today — it draws where the beats are so you can see whether the repeats\n * land on them. Built here because it is a dozen lines and the first\n * tempo-aware delay will want it; the same call `gate` made about hysteresis.\n *\n * All the arithmetic lives in `lib/echo-train`, which is pure, tested in Node,\n * and pinned against sample-accurate simulations of the two shipped delay\n * graphs. This file is the drawing.\n *\n * Styling comes from CSS custom properties — see `lib/theme.ts` — with a\n * `color` prop to override the accent for one instance.\n */\n\nimport { useMemo } from \"react\"\nimport type { CSSProperties } from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport { svgMarkerPath } from \"maruaudio/ui/shared/svg-markers\"\nimport {\n  ECHO_FLOOR,\n  MAX_ECHO_TAPS,\n  createEchoScale,\n  dryPip,\n  echoDryLevel,\n  echoSpan,\n  echoTapLevel,\n  echoTaps,\n  echoTimeGridValues,\n  formatEchoTime,\n  syncGridTicks,\n  tapPip,\n  type EchoLane,\n  type EchoTrainSpec,\n} from \"maruaudio/ui/shared/echo-train\"\n\nexport interface DelayTapDisplayProps {\n  /** Seconds between repeats. */\n  time: number\n  /** Loop gain per repeat, 0..0.95. Clamped to the audio path's ceiling. */\n  feedback: number\n  /** Wet/dry mix, 0..1. Sets the repeats' height AND the dry pip's (1 − mix). */\n  wet?: number\n\n  /** 1 for a mono train, 2 for a ping-pong's alternating sides. */\n  lanes?: 1 | 2\n  /** Which side the first repeat lands on when `lanes` is 2. Defaults to left. */\n  firstLane?: EchoLane\n  /** Names for the two lanes. Defaults to L and R. */\n  laneLabels?: readonly [string, string]\n\n  /** Seconds the axis covers. Defaults to a window that fits the train. */\n  span?: number\n  /** Stop drawing repeats below this level. Defaults to -60 dBFS. */\n  floor?: number\n  /** Stop after this many repeats. Defaults to 64. */\n  maxTaps?: number\n\n  width?: number | string\n  /** Plot height in pixels. */\n  height?: number\n  showGrid?: boolean\n  /** Draw the time numbers under the axis. */\n  showScale?: boolean\n  /** Draw the dry hit at t = 0. */\n  showDry?: boolean\n  /** Gridline spacing in seconds. Defaults to a round 1-2-5 step for the span. */\n  gridStep?: number\n\n  /**\n   * Beats-per-minute for the sync grid. Omit (or null) for no grid — which is\n   * what every shipped delay stage gets, none of them having a tempo.\n   */\n  tempo?: number | null\n  /** Sync-grid spacing in beats: 1 is a quarter, 0.5 an eighth. */\n  division?: number\n  /** Beats per bar, for the grid's labels. */\n  beatsPerBar?: number\n  /** Number the sync grid's downbeats. */\n  showSyncLabels?: boolean\n\n  /** Override the accent colour for this instance. */\n  color?: string\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\n/**\n * Faintest a pip is drawn, however quiet it is.\n *\n * The tail below -40 dB is a hairline whose height alone cannot be read; the\n * floor on opacity keeps it visible as a mark saying \"the train continues\"\n * without letting it claim a level it does not have.\n */\nconst MIN_PIP_OPACITY = 0.28\n\nexport function DelayTapDisplay({\n  time,\n  feedback,\n  wet,\n  lanes = 1,\n  firstLane = 0,\n  laneLabels = [\"L\", \"R\"],\n  span,\n  floor = ECHO_FLOOR,\n  maxTaps = MAX_ECHO_TAPS,\n  width = \"100%\",\n  height = 140,\n  showGrid = true,\n  showScale = true,\n  showDry = true,\n  gridStep,\n  tempo,\n  division = 1,\n  beatsPerBar = 4,\n  showSyncLabels = true,\n  color,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: DelayTapDisplayProps) {\n  const accent = color ?? token.accent\n\n  const spec = useMemo<EchoTrainSpec>(\n    () => ({ time, feedback, wet, lanes, firstLane }),\n    [time, feedback, wet, lanes, firstLane]\n  )\n  const options = useMemo(() => ({ floor, maxTaps }), [floor, maxTaps])\n\n  const window = span && span > 0 ? span : echoSpan(spec, options)\n  const scale = useMemo(\n    () => createEchoScale({ span: window, lanes }),\n    [window, lanes]\n  )\n  // The span bounds the train as well as the axis: a caller who fixes the\n  // window gets the pips that fit in it, not a row of pips off the right edge.\n  const taps = useMemo(\n    () => echoTaps(spec, { ...options, span: window }),\n    [spec, options, window]\n  )\n\n  const { padding: pad, innerWidth: innerW, innerHeight: innerH } = scale\n  const marks = showGrid || showScale ? echoTimeGridValues(scale, gridStep) : []\n  const ticks =\n    tempo && tempo > 0\n      ? syncGridTicks(tempo, scale.span, { division, beatsPerBar })\n      : []\n\n  // Pips thin out as the train gets long, so a singing delay reads as a comb\n  // rather than as a filled block. Two units of gap at minimum.\n  const pipWidth =\n    taps.length > 0 ? Math.min(4, Math.max(1, innerW / (taps.length * 2.4))) : 4\n\n  const dry = echoDryLevel(spec)\n  const dryLanes: EchoLane[] = lanes === 2 ? [0, 1] : [0]\n\n  // Whether the cap, rather than the floor, is what ended the train — worth\n  // saying out loud, because a train that stops at a round 64 has not decayed.\n  const truncated =\n    taps.length > 0 &&\n    taps.length === maxTaps &&\n    echoTapLevel(taps.length + 1, spec) >= floor\n\n  const label =\n    ariaLabel ??\n    `Delay tap train: ${taps.length} repeat${taps.length === 1 ? \"\" : \"s\"} every ${formatEchoTime(\n      time\n    )}${lanes === 2 ? \", alternating channels\" : \"\"}${\n      showDry && dry > 0 ? `, dry at ${Math.round(dry * 100)}%` : \"\"\n    }`\n\n  return (\n    <div\n      className={className}\n      style={{ width, fontFamily: token.font, ...style }}\n    >\n      <svg\n        viewBox={`0 0 ${scale.width} ${scale.height}`}\n        width=\"100%\"\n        height={height}\n        role=\"img\"\n        aria-label={label}\n        className=\"uikit-control\"\n        preserveAspectRatio=\"none\"\n        style={{\n          display: \"block\",\n          background: token.surface,\n          borderRadius: token.radius,\n          border: `1px solid ${token.border}`,\n        }}\n      >\n        {/* plot ground */}\n        <rect\n          x={pad}\n          y={pad}\n          width={innerW}\n          height={innerH}\n          fill={token.text}\n          opacity={0.03}\n        />\n\n        {/* the beat grid, behind the time grid: it is the musical reference the\n            taps are being judged against, not a reading of them. */}\n        {ticks.length > 0 && (\n          <g>\n            {ticks.map((tick) => (\n              <line\n                key={`beat-${tick.beat}`}\n                x1={scale.timeToX(tick.time)}\n                y1={pad}\n                x2={scale.timeToX(tick.time)}\n                y2={pad + innerH}\n                stroke={token.accent2}\n                strokeWidth={tick.downbeat ? 1 : 0.6}\n                opacity={tick.downbeat ? 0.5 : 0.22}\n                strokeDasharray={tick.downbeat ? undefined : \"2 3\"}\n              />\n            ))}\n            {showSyncLabels &&\n              ticks\n                .filter((tick) => tick.downbeat)\n                .map((tick) => (\n                  <text\n                    key={`beat-label-${tick.beat}`}\n                    x={scale.timeToX(tick.time) + 2}\n                    y={pad + 7}\n                    fill={token.accent2}\n                    fontSize={6.5}\n                    fontFamily={token.fontMono}\n                    opacity={0.75}\n                  >\n                    {tick.label}\n                  </text>\n                ))}\n          </g>\n        )}\n\n        {showGrid && (\n          <g stroke={token.border} strokeWidth={0.75} opacity={0.5}>\n            {marks.map((value) => (\n              <line\n                key={`grid-${value}`}\n                x1={scale.timeToX(value)}\n                y1={pad}\n                x2={scale.timeToX(value)}\n                y2={pad + innerH}\n              />\n            ))}\n          </g>\n        )}\n\n        {/* the baseline the pips stand on — the midline when there are two lanes,\n            which is the one line both channels share and so is the time axis. */}\n        <line\n          x1={pad}\n          y1={scale.laneBaseline(0)}\n          x2={pad + innerW}\n          y2={scale.laneBaseline(0)}\n          stroke={token.border}\n          strokeWidth={1}\n        />\n\n        {lanes === 2 && (\n          <g fill={token.textMuted} fontSize={7} letterSpacing=\"0.08em\">\n            <text x={pad + 2} y={pad + 8}>\n              {laneLabels[0]}\n            </text>\n            <text x={pad + 2} y={scale.height - pad - 3}>\n              {laneLabels[1]}\n            </text>\n          </g>\n        )}\n\n        {/* the dry hit: a reference, not a repeat. Drawn in both lanes of a\n            two-lane view because the dry path is not in the delay loop at all —\n            only the loop's seed is summed to mono, so the dry copy keeps the\n            source's own centred image. */}\n        {showDry &&\n          dry > 0 &&\n          dryLanes.map((lane) => {\n            const pip = dryPip(spec, scale, lane)\n            const marker = svgMarkerPath(pip.x, pip.tipY)\n            const diameter = Math.max(5, Math.min(7, pipWidth * 1.5))\n            return (\n              <g key={`dry-${lane}`}>\n                <line\n                  x1={pip.x}\n                  y1={pip.baseY}\n                  x2={pip.x}\n                  y2={pip.tipY}\n                  stroke={token.text}\n                  strokeWidth={pipWidth}\n                  strokeLinecap=\"round\"\n                  vectorEffect=\"non-scaling-stroke\"\n                  opacity={0.45}\n                />\n                <path\n                  d={marker}\n                  fill=\"none\"\n                  stroke={token.text}\n                  strokeWidth={diameter + 2}\n                  strokeLinecap=\"round\"\n                  vectorEffect=\"non-scaling-stroke\"\n                  opacity={0.55}\n                />\n                <path\n                  d={marker}\n                  fill=\"none\"\n                  stroke={token.surface}\n                  strokeWidth={diameter}\n                  strokeLinecap=\"round\"\n                  vectorEffect=\"non-scaling-stroke\"\n                  opacity={0.55}\n                />\n              </g>\n            )\n          })}\n\n        {/* the train */}\n        <g>\n          {taps.map((tap) => {\n            const pip = tapPip(tap, scale)\n            const marker = svgMarkerPath(pip.x, pip.tipY)\n            const markerDiameter = Math.max(5, Math.min(7, pipWidth * 1.5))\n            // Opacity tracks level as well as height does. See the header: a\n            // two-unit pip needs the second cue to be seen at all.\n            const opacity =\n              MIN_PIP_OPACITY + (1 - MIN_PIP_OPACITY) * Math.min(1, tap.level)\n            return (\n              <g key={tap.index}>\n                <line\n                  x1={pip.x}\n                  y1={pip.baseY}\n                  x2={pip.x}\n                  y2={pip.tipY}\n                  stroke={accent}\n                  strokeWidth={pipWidth}\n                  strokeLinecap=\"round\"\n                  vectorEffect=\"non-scaling-stroke\"\n                  opacity={opacity}\n                />\n                {pipWidth >= 2.5 && (\n                  <path\n                    d={marker}\n                    fill=\"none\"\n                    stroke={accent}\n                    strokeWidth={markerDiameter}\n                    strokeLinecap=\"round\"\n                    vectorEffect=\"non-scaling-stroke\"\n                    opacity={opacity}\n                  />\n                )}\n              </g>\n            )\n          })}\n        </g>\n\n        {/* the train ran out of budget rather than out of level */}\n        {truncated && (\n          <text\n            x={pad + innerW}\n            y={scale.laneBaseline(0) - 4}\n            textAnchor=\"end\"\n            fill={token.textMuted}\n            fontSize={8}\n            fontFamily={token.fontMono}\n          >\n            +more\n          </text>\n        )}\n\n        {/* nothing to draw is a state, not a blank */}\n        {taps.length === 0 && (\n          <text\n            x={scale.width / 2}\n            y={scale.laneBaseline(0) - 10}\n            textAnchor=\"middle\"\n            fill={token.textMuted}\n            fontSize={8}\n          >\n            {wet === 0 ? \"no repeats at zero mix\" : \"no repeats\"}\n          </text>\n        )}\n\n        {showScale && (\n          <g fill={token.textMuted} fontSize={7} fontFamily={token.fontMono}>\n            {marks.map((value, index) => (\n              <text\n                key={`label-${value}`}\n                x={scale.timeToX(value)}\n                y={scale.height - pad + 9}\n                // Only a mark actually sitting on an edge is anchored to it. A\n                // round grid step rarely lands on the span, and anchoring the\n                // last label as if it did pulls it off its own gridline.\n                textAnchor={\n                  index === 0\n                    ? \"start\"\n                    : value >= scale.span - 1e-9\n                      ? \"end\"\n                      : \"middle\"\n                }\n              >\n                {formatEchoTime(value)}\n              </text>\n            ))}\n          </g>\n        )}\n      </svg>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/delay-tap-display.tsx"
    }
  ]
}
