Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 Down Expand Up @@ -108,6 +109,7 @@ export default [
IntersectionObserver: 'readonly',
IntersectionObserverEntry: 'readonly',
MutationObserver: 'readonly',
ResizeObserver: 'readonly',
// IndexedDB
indexedDB: 'readonly',
IDBDatabase: 'readonly',
Expand All @@ -118,6 +120,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>
),
};
159 changes: 159 additions & 0 deletions src/components/LiveWaveform/LiveWaveform.tsx
Original file line number Diff line number Diff line change
@@ -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
* <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 =
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 (
<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 @@ -78,6 +78,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/MedicationList';
Expand Down Expand Up @@ -187,6 +188,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