{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-consonance-matrix",
  "type": "registry:ui",
  "title": "Consonance Matrix",
  "description": "Which of the twelve notes fit what is sounding now — a playing aid, not an analysis. Each pitch class is shaded by how well it agrees with a blend of the held chord and the tonal field the piece has established, so a note belonging to both glows brightest. The same maths the reference hardware lights its strum-bar LEDs with, including the min-max normalisation that turns narrowly-clustered consonance scores into a ranking you can actually read.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/consonance-matrix.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/consonance-matrix\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 * Consonance Matrix — which of the twelve notes fit what is sounding now.\n *\n * Twelve cells, C through B, each shaded by how well that pitch class agrees\n * with the current harmonic context. Bright means \"this note will land\"; dim\n * means \"this note will fight\". It is a playing aid rather than an analysis: the\n * question it answers is *what should I play next*, and the answer is legible at\n * a glance without reading a single label.\n *\n * The context has two halves, and blending them is what makes the readout\n * useful:\n *\n *   referencePcs   what is sounding RIGHT NOW — the held chord. Fast-moving.\n *   histogram      what the piece has been doing — the tonal field. Slow-moving.\n *\n * Either alone is worse. The held chord alone would relight the whole row on\n * every chord change and lose the key entirely; the histogram alone would never\n * respond to what your hands are doing. Blended (`chordWeight`, default an even\n * 0.5), a note that belongs to both the current chord AND the key glows\n * brightest — which is exactly the note that will sound most resolved.\n *\n * The shading is min-max normalised across the twelve, NOT absolute. This is\n * deliberate and worth understanding before reading the display: raw consonance\n * scores cluster in a narrow band, so an absolute scale would render twelve\n * near-identical grey cells. Normalising against the current spread turns that\n * into a visible ranking. The consequence is that the row always has a brightest\n * and a dimmest cell — even in a context where everything fits about equally.\n * Read it as a RANKING, not as a set of absolute verdicts.\n *\n * This is the same maths the reference hardware lights its strum-bar LEDs with —\n * `fitToReference` over `blendReferences`, run through `normaliseToBand` — so\n * the on-screen row and the physical instrument agree cell for cell.\n *\n * Ported from codex-music-mono's `apps/aura-daemon/src/engine/chord-consonance.ts`\n * (the `strumNoteBrightnesses` chain, applied to all twelve pitch classes rather\n * than to a strum bar's note assignment). The scoring, blending and band\n * normalisation live in `lib/consonance`; this file is the rendering and the\n * twelve-cell framing.\n *\n * SSR-safe: pure markup computed from props, no measurement, no effects.\n *\n * Styling comes from CSS custom properties — see `lib/control-theme/theme.ts`.\n */\n\nimport type { CSSProperties } from \"react\"\nimport { pitchClassName } from \"maruaudio/ui/shared/music-theory-pitch-class\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  blendReferences,\n  fitToReference,\n  normaliseToBand,\n} from \"maruaudio/ui/shared/consonance\"\n\n/**\n * The luminance band the twelve weights are mapped into, and the contrast curve\n * applied on the way.\n *\n * Carried over from the reference strum bar: a floor of 0.3 rather than 0 keeps\n * the worst-fitting note visible (it is a suggestion, not a prohibition), and\n * the 1.3 exponent pushes middling notes down so the genuinely good ones stand\n * out.\n */\nexport const MATRIX_LUM_MIN = 0.3\nexport const MATRIX_LUM_MAX = 0.95\nexport const MATRIX_CONTRAST = 1.3\n\n/**\n * The twelve fit weights for a harmonic context, one per pitch class.\n *\n * Pure: no DOM, no React. Blends the held chord with the histogram, scores every\n * pitch class against the blend, then min-max normalises into the luminance\n * band — the full chain, in the same order the reference hardware runs it.\n *\n * Returns twelve values in `[MATRIX_LUM_MIN, MATRIX_LUM_MAX]`, chromatic order,\n * index 0 = C. With no context at all, every pitch class scores identically and\n * the row comes back flat at the band's midpoint, which is the honest reading:\n * nothing is sounding, so nothing fits better than anything else.\n *\n * NOT exactly transposition-invariant, and deliberately so — the same property\n * the reference hardware has. The scoring uses the register-dependent\n * psychoacoustic model, and every pitch class is evaluated inside a single\n * octave, so a pair a fifth apart as pitch classes may be a fourth DOWN as\n * notes. Transposing a context therefore reshuffles the underlying intervals\n * slightly. The RANKING survives transposition, which is what the display\n * promises; the exact weights do not.\n *\n * `referencePcs` is a list of pitch classes (raw MIDI works — it is reduced mod\n * 12), not a weight field; each named pitch class counts once. Pass a 12-bin\n * field as `histogram` if you need weighted input.\n */\nexport function consonanceRow(\n  referencePcs: ReadonlyArray<number> = [],\n  histogram: ReadonlyArray<number> = [],\n  chordWeight = 0.5,\n): number[] {\n  // A pitch-class list becomes a 12-bin field with a unit weight per named class.\n  const held = new Array(12).fill(0) as number[]\n  for (const n of referencePcs ?? []) {\n    if (typeof n === \"number\" && Number.isFinite(n)) {\n      held[((n % 12) + 12) % 12] = 1\n    }\n  }\n\n  const reference = blendReferences(held, histogram ?? [], chordWeight)\n  const raw = Array.from({ length: 12 }, (_, pc) => fitToReference([pc], reference))\n\n  return normaliseToBand(raw, MATRIX_LUM_MIN, MATRIX_LUM_MAX, MATRIX_CONTRAST)\n}\n\nexport interface ConsonanceMatrixProps {\n  /**\n   * Pitch classes sounding right now — typically the held chord. Reduced mod 12,\n   * so raw MIDI note numbers work.\n   */\n  referencePcs?: ReadonlyArray<number>\n  /**\n   * Length-12 pitch-class weights in CHROMATIC order (0 = C … 11 = B): the tonal\n   * field the piece has established. Units are arbitrary — it is sum-normalised\n   * before blending.\n   */\n  histogram?: ReadonlyArray<number>\n  /**\n   * How much the held chord counts against the histogram, 0..1. 1 is the chord\n   * alone, 0 the histogram alone.\n   */\n  chordWeight?: number\n  /** Draw the note name in each cell. */\n  labels?: boolean\n  /** Cell colour. Defaults to the accent token; brightness varies per cell. */\n  color?: string\n  /** Height of the cells in pixels. */\n  height?: number\n  /** Overall width. Defaults to filling the container. */\n  width?: number | string\n  /** Mark this pitch class as the root/tonic with a tick beneath its cell. */\n  rootPc?: number\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\nexport function ConsonanceMatrix({\n  referencePcs,\n  histogram,\n  chordWeight = 0.5,\n  labels = true,\n  color,\n  height = 44,\n  width = \"100%\",\n  rootPc,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: ConsonanceMatrixProps) {\n  const weights = consonanceRow(referencePcs, histogram, chordWeight)\n  const accent = color ?? token.accent\n  const held = new Set((referencePcs ?? []).map((n) => ((n % 12) + 12) % 12))\n  const root = rootPc === undefined ? undefined : ((rootPc % 12) + 12) % 12\n\n  return (\n    <div\n      className={`uikit-control${className ? ` ${className}` : \"\"}`}\n      role=\"img\"\n      aria-label={ariaLabel ?? \"Consonance of each pitch class against the current harmony\"}\n      style={{\n        display: \"flex\",\n        alignItems: \"stretch\",\n        gap: 3,\n        width,\n        padding: 8,\n        boxSizing: \"border-box\",\n        background: token.surface,\n        borderRadius: token.radius,\n        border: `1px solid ${token.border}`,\n        fontFamily: token.font,\n        ...style,\n      }}\n    >\n      {weights.map((weight, pc) => {\n        const sounding = held.has(pc)\n        const isRoot = root === pc\n        return (\n          <div\n            key={pc}\n            aria-description={`${pitchClassName(pc, \"unicode-sharp\")} — fit ${(weight * 100).toFixed(0)}%`}\n            style={{\n              flex: \"1 1 0\",\n              minWidth: 0,\n              display: \"flex\",\n              flexDirection: \"column\",\n              alignItems: \"center\",\n              gap: 4,\n            }}\n          >\n            <div\n              style={{\n                width: \"100%\",\n                height,\n                borderRadius: 3,\n                background: accent,\n                // The weight IS the opacity — that is the whole readout.\n                opacity: weight,\n                // A ring rather than a colour change, so the \"currently held\"\n                // marker does not fight the `color` prop or the brightness.\n                outline: sounding ? `1.5px solid ${accent}` : undefined,\n                outlineOffset: 1,\n                display: \"flex\",\n                alignItems: \"center\",\n                justifyContent: \"center\",\n              }}\n            >\n              {labels && (\n                <span\n                  style={{\n                    fontSize: 10,\n                    lineHeight: 1,\n                    fontWeight: sounding ? 700 : 500,\n                    // Punched out of the cell, so it stays legible however\n                    // bright the cell behind it is.\n                    color: token.surface,\n                    mixBlendMode: \"difference\",\n                  }}\n                >\n                  {pitchClassName(pc, \"unicode-sharp\")}\n                </span>\n              )}\n            </div>\n\n            {/* The root tick — a short underline that reads as an anchor. */}\n            <div\n              style={{\n                width: \"60%\",\n                height: 2,\n                borderRadius: 1,\n                background: isRoot ? accent : \"transparent\",\n              }}\n            />\n          </div>\n        )\n      })}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/consonance-matrix.tsx"
    }
  ]
}
