Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export default [
navigator: 'readonly',
MediaRecorder: 'readonly',
MediaStream: 'readonly',
MediaStreamTrack: 'readonly',
MediaStreamConstraints: 'readonly',
AudioContext: 'readonly',
AudioBuffer: 'readonly',
Expand All @@ -94,6 +95,7 @@ export default [
IntersectionObserver: 'readonly',
IntersectionObserverEntry: 'readonly',
MutationObserver: 'readonly',
ResizeObserver: 'readonly',
// IndexedDB
indexedDB: 'readonly',
IDBDatabase: 'readonly',
Expand All @@ -104,6 +106,7 @@ export default [
// DOM types
Element: 'readonly',
Document: 'readonly',
DOMException: 'readonly',
ScrollBehavior: 'readonly',
// Types
PermissionName: 'readonly',
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
123 changes: 123 additions & 0 deletions src/components/LiveWaveform/LiveWaveform.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { Meta, StoryObj } from '@storybook/react';
import * as React from 'react';
import { LiveWaveform } from './LiveWaveform';

const meta: Meta<typeof LiveWaveform> = {
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';

<LiveWaveform stream={captureStream} active={isRecording} height={56} />
\`\`\`
`,
},
},
},
tags: ['autodocs'],
};

export default meta;
type Story = StoryObj<typeof LiveWaveform>;

/**
* 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<MediaStream | null>(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 (
<div className="flex w-80 flex-col items-center gap-4">
<div className="bg-muted h-14 w-full overflow-hidden rounded-lg">
<LiveWaveform
stream={stream}
active={active}
height={height}
color={color}
/>
</div>
<button
type="button"
onClick={toggle}
className="bg-primary-800 rounded-md px-4 py-2 text-sm text-white"
>
{active ? 'Stop' : 'Play tone'}
</button>
</div>
);
}

export const Default: Story = {
render: () => <SyntheticWaveform height={56} />,
};

export const CustomColor: Story = {
render: () => <SyntheticWaveform height={56} color="#ef4444" />,
};

export const Idle: Story = {
args: { stream: null, active: false, height: 56 },
render: (args) => (
<div className="bg-muted h-14 w-80 overflow-hidden rounded-lg">
<LiveWaveform {...args} />
</div>
),
};
158 changes: 158 additions & 0 deletions src/components/LiveWaveform/LiveWaveform.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
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
* <LiveWaveform stream={captureStream} active={isRecording} height={56} />
* ```
*/
export const LiveWaveform: React.FC<LiveWaveformProps> = ({
stream,
active,
height = 64,
color,
bars = 48,
className,
}) => {
const canvasRef = React.useRef<HTMLCanvasElement>(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 = new ResizeObserver(resize);
ro.observe(canvas);
Comment thread
abroa01 marked this conversation as resolved.
Outdated

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 (
<canvas
ref={canvasRef}
data-slot="live-waveform"
className={cn('h-full w-full', className)}
style={{ height }}
aria-hidden="true"
/>
);
};
1 change: 1 addition & 0 deletions src/components/LiveWaveform/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { LiveWaveform, type LiveWaveformProps } from './LiveWaveform';
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export * from './components/InvoicePaymentPage';
// InvoiceView exports InvoiceLineItem which conflicts with InvoicePaymentPage
export { InvoiceView, type InvoiceViewProps } from './components/InvoiceView';
export * from './components/LanguageSelector';
export * from './components/LiveWaveform';
export * from './components/LoadingPage';
export * from './components/Markdown';
export * from './components/Messaging';
Expand Down Expand Up @@ -174,6 +175,9 @@ export {
} from './components/WebChartReportViewer';
export * from './components/WebsiteInput';

// Media capture (microphone / screen recording strategies)
export * from './media/capture';

// Hooks
export * from './hooks';

Expand Down
46 changes: 46 additions & 0 deletions src/media/capture/MicrophoneCapture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* MicrophoneCapture — microphone-only capture via getUserMedia.
*
* Records a single audio track to a WebM/Opus (or MP4 fallback) container.
*/
import { pickSupportedMimeType, toCaptureError } from './mime';
import type { CaptureResult, RecordingCapture } from './types';

const AUDIO_MIME_CANDIDATES = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
] as const;
const AUDIO_MIME_FALLBACK = 'audio/webm';

export class MicrophoneCapture implements RecordingCapture {
private stream: MediaStream | null = null;

async start(): Promise<CaptureResult> {
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch (err: unknown) {
throw toCaptureError(
err,
'Microphone permission was denied. Allow microphone access and try again.',
'Failed to access the microphone.'
);
}
this.stream = stream;
return {
stream,
mimeType: pickSupportedMimeType(
AUDIO_MIME_CANDIDATES,
AUDIO_MIME_FALLBACK
),
mediaType: 'audio',
audioCaptured: true,
};
}

stop(): void {
this.stream?.getTracks().forEach((track) => track.stop());
this.stream = null;
}
}
Loading
Loading