{
  "$schema": "https://mian.ro/schema/v2/registry.json",
  "schemaVersion": "2.0.0",
  "generatedAt": "2026-10-02T13:14:06.957Z",
  "id": "voice-note",
  "name": "Voice note",
  "category": "Data display",
  "description": "An audio message as a waveform: play, pause, see where you are and drag to seek.",
  "tags": [
    "voice note",
    "audio message",
    "waveform",
    "audio player",
    "voice memo",
    "podcast clip",
    "chat audio",
    "recording"
  ],
  "requiresJs": true,
  "license": "LicenseRef-MIAN-1.0",
  "composition": {
    "role": null,
    "intents": [
      "voice message",
      "audio waveform player",
      "voice memo",
      "audio clip in chat"
    ],
    "prominence": null,
    "density": null,
    "worksAfter": [],
    "worksBefore": [],
    "alternatives": [
      "chat"
    ],
    "avoid": null
  },
  "url": "https://mian.ro/components/voice-note",
  "markdownUrl": "https://mian.ro/ai/components/voice-note.md",
  "classes": [
    {
      "name": "mn-voice",
      "description": "The player. Add data-mn-voice and data-duration (seconds); data-src plays a real file."
    },
    {
      "name": "mn-voice-play",
      "description": "The play / pause button, with two icons: mn-voice-go and mn-voice-pause."
    },
    {
      "name": "mn-voice-wave",
      "description": "The waveform. Bars are drawn for you; pass real peaks (0 to 1) in data-peaks."
    },
    {
      "name": "mn-voice-time",
      "description": "Elapsed time, or the length when stopped."
    }
  ],
  "accessibility": [
    "The waveform is a slider: arrow keys seek, Home and End jump, Space plays.",
    "The button’s label switches between Play and Pause.",
    "Offer a transcript next to any spoken message.",
    "Nothing plays until the person presses play."
  ],
  "examples": [
    {
      "title": "In a conversation",
      "description": null,
      "html": "<div class=\"mn-stack\" style=\"--mn-gap:.6rem;width:min(100%,24rem)\">\n  <div class=\"mn-voice\" data-mn-voice data-duration=\"42\"><button class=\"mn-voice-play\" type=\"button\" aria-label=\"Play\"><svg class=\"mn-voice-go\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M7 4.5v15l12-7.5Z\"/></svg><svg class=\"mn-voice-pause\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><rect x=\"6\" y=\"5\" width=\"4\" height=\"14\" rx=\"1\"/><rect x=\"14\" y=\"5\" width=\"4\" height=\"14\" rx=\"1\"/></svg></button><div class=\"mn-voice-wave\" aria-label=\"Voice note from Ilse, position\"></div><span class=\"mn-voice-time\">0:42</span></div>\n  <p class=\"mn-muted\" style=\"margin:0;font-size:.8125rem\">Transcript: “The proofs are in. Page six needs a wider margin, the rest is good to print.”</p>\n</div>"
    }
  ],
  "css": ".mn-voice { --_p: 0%; display: inline-flex; align-items: center; gap: .7rem; max-width: 100%; min-width: 0; padding: .45rem .9rem .45rem .45rem; border: var(--mn-border-w) solid var(--mn-border); border-radius: 999px; background: var(--mn-card); color: var(--mn-fg); }\n.mn-voice-play { display: grid; place-items: center; flex: none; width: 2.25rem; height: 2.25rem; border: 0; border-radius: 50%; background: var(--mn-primary); color: var(--mn-primary-fg); cursor: pointer; transition: scale var(--mn-dur-fast) var(--mn-ease-out); }\n.mn-voice-play:active { scale: .94; }\n.mn-voice-play:focus-visible { outline: 2px solid var(--mn-ring); outline-offset: 2px; }\n.mn-voice-play svg { width: 1rem; height: 1rem; }\n.mn-voice-play .mn-voice-pause, .mn-voice[data-playing] .mn-voice-play .mn-voice-go { display: none; }\n.mn-voice[data-playing] .mn-voice-play .mn-voice-pause { display: block; }\n.mn-voice-wave { display: flex; align-items: center; gap: 2px; flex: 1 1 9rem; min-width: 4rem; height: 1.75rem; cursor: pointer; touch-action: none; border-radius: 4px; }\n.mn-voice-wave:focus-visible { outline: 2px solid var(--mn-ring); outline-offset: 3px; }\n.mn-voice-wave > i { flex: 1 1 0; min-width: 2px; max-width: 3px; height: calc(var(--h, .4) * 100%); border-radius: 2px; background: color-mix(in oklab, var(--mn-fg) 28%, transparent); transition: background-color var(--mn-dur-fast) linear; }\n.mn-voice-wave > i.mn-on { background: var(--mn-primary-text, var(--mn-primary)); }\n.mn-voice-time { flex: none; color: var(--mn-muted); font: 500 .75rem/1 var(--mn-font-mono); font-variant-numeric: tabular-nums; }",
  "js": "/* <div class=\"mn-voice\" data-mn-voice data-duration=\"42\" [data-src=\"clip.mp3\"]>\n     <button class=\"mn-voice-play\" aria-label=\"Play\">…</button>\n     <div class=\"mn-voice-wave\" data-peaks=\"…optional comma list 0–1…\"></div>\n     <span class=\"mn-voice-time\">0:42</span></div>\n   Draws the waveform bars, plays (the audio in data-src, or a silent clock\n   for demos), fills the bars as it goes and seeks on click, drag or arrows.\n   Emits mn:change { time, playing }. */\nconst clock = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, '0')}`;\nregister('voice-note', (root) => each(root, '[data-mn-voice]', 'Voice', (el) => {\n  const wave = el.querySelector('.mn-voice-wave'), btn = el.querySelector('.mn-voice-play'), time = el.querySelector('.mn-voice-time');\n  if (!wave || !btn) return;\n  let total = Number(el.dataset.duration) || 30, at = 0, raf = 0, last = 0;\n  const audio = el.dataset.src ? new Audio(el.dataset.src) : null;\n  if (audio) { audio.preload = 'metadata'; audio.addEventListener('loadedmetadata', () => { if (isFinite(audio.duration)) { total = audio.duration; draw(); } }); audio.addEventListener('ended', () => stop(true)); }\n  let peaks = (wave.dataset.peaks || '').split(',').map(Number).filter((n) => n > 0);\n  if (!peaks.length) { let s = 7 + total; peaks = Array.from({ length: 44 }, (_, i) => { s = (s * 16807) % 2147483647; return 0.22 + 0.78 * Math.abs(Math.sin(i / 3.1)) * (0.45 + 0.55 * (s / 2147483647)); }); }\n  wave.innerHTML = peaks.map((p) => `<i style=\"--h:${Math.min(1, p).toFixed(2)}\"></i>`).join('');\n  const bars = [...wave.children];\n  wave.setAttribute('role', 'slider'); wave.setAttribute('tabindex', '0'); wave.setAttribute('aria-valuemin', '0');\n  if (!wave.hasAttribute('aria-label')) wave.setAttribute('aria-label', 'Position');\n  const draw = () => {\n    const k = total ? at / total : 0;\n    bars.forEach((b, i) => b.classList.toggle('mn-on', (i + 0.5) / bars.length <= k));\n    if (time) time.textContent = clock(el.hasAttribute('data-playing') || at > 0 ? at : total);\n    wave.setAttribute('aria-valuemax', String(Math.round(total))); wave.setAttribute('aria-valuenow', String(Math.round(at))); wave.setAttribute('aria-valuetext', `${clock(at)} of ${clock(total)}`);\n  };\n  const stop = (ended) => { cancelAnimationFrame(raf); raf = 0; el.removeAttribute('data-playing'); btn.setAttribute('aria-label', 'Play'); if (audio) audio.pause(); if (ended) at = 0; draw(); emit(el, 'change', { time: at, playing: false }); };\n  const tick = (t) => { if (audio) at = audio.currentTime; else { at += (t - last) / 1000; last = t; } if (at >= total) return stop(true); draw(); raf = requestAnimationFrame(tick); };\n  const play = () => { el.setAttribute('data-playing', ''); btn.setAttribute('aria-label', 'Pause'); if (audio) { audio.currentTime = at; audio.play().catch(() => {}); } last = performance.now(); raf = requestAnimationFrame(tick); emit(el, 'change', { time: at, playing: true }); };\n  const seek = (v) => { at = Math.max(0, Math.min(total, v)); if (audio) audio.currentTime = at; draw(); };\n  btn.addEventListener('click', () => (el.hasAttribute('data-playing') ? stop(false) : play()));\n  const fromX = (x) => { const r = wave.getBoundingClientRect(); return ((x - r.left) / r.width) * total; };\n  wave.addEventListener('pointerdown', (e) => { wave.setPointerCapture(e.pointerId); seek(fromX(e.clientX)); });\n  wave.addEventListener('pointermove', (e) => { if (wave.hasPointerCapture(e.pointerId)) seek(fromX(e.clientX)); });\n  wave.addEventListener('keydown', (e) => { const d = { ArrowLeft: -total / 20, ArrowRight: total / 20, Home: -total, End: total }[e.key]; if (d != null) { e.preventDefault(); seek(at + d); } if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); btn.click(); } });\n  draw();\n}));",
  "install": {
    "css": "https://mian.ro/dist/mian.css",
    "js": "https://mian.ro/dist/mian.js",
    "registryItem": "https://mian.ro/r/voice-note.json"
  }
}
