diff --git a/eslint.config.js b/eslint.config.js index b0de9e92..31db9ab1 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -73,6 +73,7 @@ export default [ navigator: 'readonly', MediaRecorder: 'readonly', MediaStream: 'readonly', + MediaStreamTrack: 'readonly', MediaStreamConstraints: 'readonly', AudioContext: 'readonly', AudioBuffer: 'readonly', @@ -108,6 +109,7 @@ export default [ IntersectionObserver: 'readonly', IntersectionObserverEntry: 'readonly', MutationObserver: 'readonly', + ResizeObserver: 'readonly', // IndexedDB indexedDB: 'readonly', IDBDatabase: 'readonly', @@ -118,6 +120,7 @@ export default [ // DOM types Element: 'readonly', Document: 'readonly', + DOMException: 'readonly', ScrollBehavior: 'readonly', // Types PermissionName: 'readonly', diff --git a/package.json b/package.json index 412a022f..25219bbe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mieweb/ui", - "version": "0.6.1", + "version": "0.7.0", "description": "A themeable, accessible React component library built with Tailwind CSS", "author": "Medical Informatics Engineering, Inc.", "license": "SEE LICENSE IN LICENSE", diff --git a/src/components/LiveWaveform/LiveWaveform.stories.tsx b/src/components/LiveWaveform/LiveWaveform.stories.tsx new file mode 100644 index 00000000..9a91bc35 --- /dev/null +++ b/src/components/LiveWaveform/LiveWaveform.stories.tsx @@ -0,0 +1,123 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import * as React from 'react'; +import { LiveWaveform } from './LiveWaveform'; + +const meta: Meta = { + title: 'Components/Images & Media/LiveWaveform', + component: LiveWaveform, + parameters: { + layout: 'centered', + docs: { + description: { + component: ` +A real-time volume visualizer for an active capture \`MediaStream\`. + +Unlike \`AudioRecorder\`, \`LiveWaveform\` owns no microphone — it visualizes a +stream you already have (e.g. from \`getUserMedia\` / \`getDisplayMedia\` or a +recording pipeline), so it can share the recorder's stream without contending +for the device. Bars are vertically centered and the color defaults to the +active brand's \`--color-primary-500\`. + +\`\`\`tsx +import { LiveWaveform } from '@mieweb/ui'; + + +\`\`\` + `, + }, + }, + }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +/** + * Synthetic demo: builds an oscillator-backed stream so the waveform animates + * without requesting microphone permission. + */ +function SyntheticWaveform({ + height, + color, +}: { + height?: number; + color?: string; +}) { + const [active, setActive] = React.useState(false); + const [stream, setStream] = React.useState(null); + const cleanupRef = React.useRef<(() => void) | null>(null); + + const toggle = () => { + if (active) { + cleanupRef.current?.(); + cleanupRef.current = null; + setStream(null); + setActive(false); + return; + } + + const ctx = new AudioContext(); + const oscillator = ctx.createOscillator(); + const lfo = ctx.createOscillator(); + const lfoGain = ctx.createGain(); + const destination = ctx.createMediaStreamDestination(); + + oscillator.type = 'sawtooth'; + oscillator.frequency.value = 220; + lfo.frequency.value = 4; + lfoGain.gain.value = 120; + lfo.connect(lfoGain).connect(oscillator.frequency); + oscillator.connect(destination); + oscillator.start(); + lfo.start(); + + cleanupRef.current = () => { + oscillator.stop(); + lfo.stop(); + void ctx.close(); + }; + + setStream(destination.stream); + setActive(true); + }; + + React.useEffect(() => () => cleanupRef.current?.(), []); + + return ( +
+
+ +
+ +
+ ); +} + +export const Default: Story = { + render: () => , +}; + +export const CustomColor: Story = { + render: () => , +}; + +export const Idle: Story = { + args: { stream: null, active: false, height: 56 }, + render: (args) => ( +
+ +
+ ), +}; diff --git a/src/components/LiveWaveform/LiveWaveform.tsx b/src/components/LiveWaveform/LiveWaveform.tsx new file mode 100644 index 00000000..78aeb33e --- /dev/null +++ b/src/components/LiveWaveform/LiveWaveform.tsx @@ -0,0 +1,159 @@ +import * as React from 'react'; +import { cn } from '../../utils/cn'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface LiveWaveformProps { + /** Active capture stream to visualize; `null` renders an empty canvas. */ + stream: MediaStream | null; + /** Whether the visualizer should animate (false clears the canvas). */ + active: boolean; + /** Canvas height in pixels. */ + height?: number; + /** + * Bar color. Defaults to the theme's `--color-primary-500` (falling back to + * `#27aae1`), so it adapts to the active brand without configuration. + */ + color?: string; + /** Number of bars to render across the width. */ + bars?: number; + /** Additional class names for the canvas. */ + className?: string; +} + +// ============================================================================ +// Helpers +// ============================================================================ + +/** Resolve the bar color, preferring the explicit prop then the theme token. */ +function resolveBarColor(color?: string): string { + if (color) return color; + if (typeof window === 'undefined') return '#27aae1'; + const themeColor = getComputedStyle(document.documentElement) + .getPropertyValue('--color-primary-500') + .trim(); + return themeColor || '#27aae1'; +} + +// ============================================================================ +// LiveWaveform +// ============================================================================ + +/** + * LiveWaveform — real-time volume visualizer for an active capture stream. + * + * Connects a Web Audio `AnalyserNode` to the provided `MediaStream` and draws + * evenly-spaced, vertically-centered rounded bars that fill the width of their + * container. It owns no microphone of its own, so it can safely share a + * recorder's stream without contending for the device. + * + * @example + * ```tsx + * + * ``` + */ +export const LiveWaveform: React.FC = ({ + stream, + active, + height = 64, + color, + bars = 48, + className, +}) => { + const canvasRef = React.useRef(null); + + React.useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const clear = () => { + const ctx = canvas.getContext('2d'); + ctx?.clearRect(0, 0, canvas.width, canvas.height); + }; + + if (!stream || !active || stream.getAudioTracks().length === 0) { + clear(); + return; + } + + const fillColor = resolveBarColor(color); + const audioCtx = new AudioContext(); + const source = audioCtx.createMediaStreamSource(stream); + const analyser = audioCtx.createAnalyser(); + analyser.fftSize = 256; + analyser.smoothingTimeConstant = 0.8; + source.connect(analyser); + + const bufferLength = analyser.frequencyBinCount; + const data = new Uint8Array(bufferLength); + let raf = 0; + + const resize = () => { + const dpr = window.devicePixelRatio || 1; + const w = canvas.clientWidth || 300; + const h = canvas.clientHeight || height; + canvas.width = Math.round(w * dpr); + canvas.height = Math.round(h * dpr); + }; + resize(); + const ro = + typeof ResizeObserver !== 'undefined' ? new ResizeObserver(resize) : null; + ro?.observe(canvas); + + const draw = () => { + const ctx = canvas.getContext('2d'); + if (!ctx) return; + const dpr = window.devicePixelRatio || 1; + const w = canvas.width / dpr; + const h = canvas.height / dpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + + analyser.getByteFrequencyData(data); + + const gap = 2; + const barWidth = Math.max(2, (w - gap * (bars - 1)) / bars); + const mid = h / 2; + // Sample the voice-relevant low-to-mid bins spread across the bar count. + const usableBins = Math.max(1, Math.floor(bufferLength * 0.7)); + + ctx.fillStyle = fillColor; + for (let i = 0; i < bars; i += 1) { + const bin = Math.floor((i / bars) * usableBins); + const v = data[bin] / 255; + const barHeight = Math.max(barWidth, v * h * 0.9); + const x = i * (barWidth + gap); + const y = mid - barHeight / 2; + if (typeof ctx.roundRect === 'function') { + ctx.beginPath(); + ctx.roundRect(x, y, barWidth, barHeight, barWidth / 2); + ctx.fill(); + } else { + ctx.fillRect(x, y, barWidth, barHeight); + } + } + + raf = requestAnimationFrame(draw); + }; + draw(); + + return () => { + cancelAnimationFrame(raf); + ro?.disconnect(); + source.disconnect(); + void audioCtx.close(); + }; + }, [stream, active, color, bars, height]); + + return ( +