Recording
Record audio directly from the microphone with level monitoring and waveform visualization.
Installation​
Install the recording package:
npm install @waveform-playlist/recording
Basic Recording​
import { useState } from 'react';
import {
WaveformPlaylistProvider,
Waveform,
} from '@waveform-playlist/browser';
import { useAudioTracks } from '@waveform-playlist/browser/tone';
import {
useRecording,
useMicrophoneAccess,
} from '@waveform-playlist/recording';
function RecordButton() {
const { requestAccess, hasPermission, stream } = useMicrophoneAccess();
const { isRecording, startRecording, stopRecording } = useRecording(stream);
const handleRecord = async () => {
if (!hasPermission) {
await requestAccess();
}
if (isRecording) {
stopRecording();
} else {
startRecording();
}
};
return (
<button onClick={handleRecord}>
{isRecording ? 'Stop Recording' : 'Start Recording'}
</button>
);
}
function RecordingExample() {
const { tracks } = useAudioTracks([]);
return (
<WaveformPlaylistProvider tracks={tracks} timescale>
<RecordButton />
<Waveform />
</WaveformPlaylistProvider>
);
}
Microphone Access​
useMicrophoneAccess Hook​
Request and manage microphone permissions:
import { useMicrophoneAccess } from '@waveform-playlist/recording';
function MicrophoneSetup() {
const {
hasPermission,
isLoading,
error,
requestAccess,
stopStream,
} = useMicrophoneAccess();
if (isLoading) {
return <div>Requesting microphone access...</div>;
}
if (error) {
return <div>Microphone error: {error.message}</div>;
}
return (
<div>
<p>Microphone access: {hasPermission ? 'Granted' : 'Not granted'}</p>
{!hasPermission ? (
<button onClick={() => requestAccess()}>Grant Microphone Access</button>
) : (
<button onClick={stopStream}>Revoke Access</button>
)}
</div>
);
}
Audio Constraints​
Customize microphone settings:
const { requestAccess } = useMicrophoneAccess();
// Pass audio constraints when requesting access:
await requestAccess(undefined, {
echoCancellation: false, // Preserve raw audio
noiseSuppression: false, // No processing
autoGainControl: false, // Manual gain control
sampleRate: 48000, // Higher sample rate
});
Default constraints optimize for recording quality:
| Constraint | Default | Purpose |
|---|---|---|
echoCancellation | false | Preserve raw audio |
noiseSuppression | false | No processing artifacts |
autoGainControl | false | Consistent levels |
latency | 0 | Low latency monitoring |
Recording Controls​
useRecording Hook​
The main hook for recording functionality. It requires a MediaStream from useMicrophoneAccess:
import { useRecording, useMicrophoneAccess } from '@waveform-playlist/recording';
function RecordingControls() {
const { stream } = useMicrophoneAccess();
const {
isRecording,
isPaused,
duration,
audioBuffer,
startRecording,
stopRecording,
pauseRecording,
resumeRecording,
} = useRecording(stream);
return (
<div>
<p>Recording: {isRecording ? 'Yes' : 'No'}</p>
<p>Duration: {duration.toFixed(1)}s</p>
{!isRecording ? (
<button onClick={startRecording}>Record</button>
) : (
<>
<button onClick={stopRecording}>Stop</button>
<button onClick={isPaused ? resumeRecording : pauseRecording}>
{isPaused ? 'Resume' : 'Pause'}
</button>
</>
)}
{audioBuffer && (
<p>Recorded {audioBuffer.duration.toFixed(1)}s of audio</p>
)}
</div>
);
}
Mic Disconnect Detection​
useRecording automatically detects when the microphone is unplugged or permission is revoked during recording. When the MediaStreamTrack 'ended' event fires, the hook calls stopRecording() and sets an error state:
const { error } = useRecording(stream);
// error.message === 'Microphone disconnected during recording'
{error && <p style={{ color: 'red' }}>{error.message}</p>}
:::tip Sample Rate Mismatch
The mic's native sample rate (from MediaStreamTrack.getSettings().sampleRate) may differ from the AudioContext.sampleRate. The browser automatically resamples at the MediaStreamAudioSourceNode boundary. To avoid resampling, match input and output sample rates in your system audio settings (e.g., macOS Audio MIDI Setup).
:::
Accessing Recorded Audio Data​
After recording stops, the audioBuffer from useRecording contains the full recorded audio as a Web Audio API AudioBuffer. You can also await the return value of stopRecording() to get the buffer directly.
Getting the AudioBuffer​
import { useRecording, useMicrophoneAccess } from '@waveform-playlist/recording';
function RecordingWithDataAccess() {
const { stream } = useMicrophoneAccess();
const {
isRecording,
audioBuffer,
startRecording,
stopRecording,
} = useRecording(stream);
const handleStopAndProcess = async () => {
// Option 1: Await stopRecording() directly
const buffer = await stopRecording();
if (buffer) {
console.log('Duration:', buffer.duration, 'seconds');
console.log('Sample rate:', buffer.sampleRate);
console.log('Channels:', buffer.numberOfChannels);
}
};
// Option 2: Access audioBuffer state after recording
useEffect(() => {
if (audioBuffer) {
console.log('Recording complete:', audioBuffer.duration, 'seconds');
}
}, [audioBuffer]);
return (
<button onClick={isRecording ? handleStopAndProcess : startRecording}>
{isRecording ? 'Stop' : 'Record'}
</button>
);
}
Extracting Raw PCM Data​
The AudioBuffer provides access to raw PCM sample data via getChannelData():
function processRecordedAudio(audioBuffer: AudioBuffer) {
// Get raw PCM samples for each channel
const leftChannel = audioBuffer.getChannelData(0); // Float32Array
const rightChannel = audioBuffer.numberOfChannels > 1
? audioBuffer.getChannelData(1)
: leftChannel;
console.log('Samples:', leftChannel.length);
console.log('Sample rate:', audioBuffer.sampleRate);
console.log('Duration:', audioBuffer.duration, 'seconds');
// Sample values are floats between -1.0 and 1.0
const peakLevel = Math.max(...leftChannel.map(Math.abs));
console.log('Peak level:', peakLevel);
return { leftChannel, rightChannel };
}
Converting to Other Formats​
You can convert the recorded audio to various formats:
// Convert to 16-bit integer PCM
function toInt16PCM(floatData: Float32Array): Int16Array {
const int16 = new Int16Array(floatData.length);
for (let i = 0; i < floatData.length; i++) {
// Clamp and convert float [-1, 1] to int16 [-32768, 32767]
const s = Math.max(-1, Math.min(1, floatData[i]));
int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
return int16;
}
// Example usage
const pcmData = audioBuffer.getChannelData(0);
const int16Data = toInt16PCM(pcmData);
Sending to a Server​
async function uploadRecording(audioBuffer: AudioBuffer) {
// Get raw PCM data
const pcmData = audioBuffer.getChannelData(0);
// Send as Float32Array
const response = await fetch('/api/upload-audio', {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: pcmData.buffer,
});
return response.json();
}
For WAV file export, use the useExportWav hook or ExportWavButton component described in the Download Recorded Audio section.
Level Monitoring​
useMicrophoneLevel Hook​
Display real-time input levels. Pass channelCount: 2 to monitor stereo inputs:
import { useMicrophoneAccess, useMicrophoneLevel } from '@waveform-playlist/recording';
function LevelMeter() {
const { stream } = useMicrophoneAccess();
const { levels, peakLevels } = useMicrophoneLevel(stream, { channelCount: 2 });
// levels: number[] (per-channel peak levels, 0-1)
// peakLevels: number[] (per-channel held peak levels with decay, 0-1)
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{levels.map((level, ch) => (
<div key={ch} style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<label>{ch === 0 ? 'L' : 'R'}</label>
<div style={{ width: '200px', height: '20px', background: '#ddd' }}>
<div
style={{
width: `${level * 100}%`,
height: '100%',
background: level > 0.9 ? 'red' : level > 0.7 ? 'yellow' : 'green',
transition: 'width 50ms',
}}
/>
</div>
<div style={{ width: '200px', height: '20px', background: '#ddd' }}>
<div
style={{
width: `${peakLevels[ch] * 100}%`,
height: '100%',
background: peakLevels[ch] > 0.9 ? 'red' : '#333',
}}
/>
</div>
</div>
))}
</div>
);
}
For advanced metering including output meters and custom visualizations, see the VU Meters guide.
Clipping Indicator​
Warn users when audio is too loud:
function ClippingIndicator({ stream }: { stream: MediaStream | null }) {
const { peakLevels } = useMicrophoneLevel(stream);
const isClipping = peakLevels.some((peak) => peak > 0.95);
return (
<div
style={{
padding: '0.5rem 1rem',
background: isClipping ? '#ff4444' : '#44ff44',
color: isClipping ? 'white' : 'black',
borderRadius: '4px',
}}
>
{isClipping ? 'CLIPPING!' : 'Levels OK'}
</div>
);
}
Adding Recorded Audio to Playlist​
For multi-track recording with automatic track management, use the useIntegratedRecording hook from @waveform-playlist/recording:
import { useIntegratedRecording } from '@waveform-playlist/recording';
import type { ClipTrack } from '@waveform-playlist/core';
function RecordToPlaylist() {
const [tracks, setTracks] = useState<ClipTrack[]>([]);
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null);
const {
isRecording,
startRecording,
stopRecording,
requestMicAccess,
hasPermission,
} = useIntegratedRecording(tracks, setTracks, selectedTrackId);
const handleRecord = async () => {
if (!hasPermission) {
await requestMicAccess();
}
if (isRecording) {
// Stop recording — the clip lands at the record-start position and
// replaces any overlapped clip content on the track (punch-in)
stopRecording();
} else {
startRecording();
}
};
return (
<button onClick={handleRecord}>
{isRecording ? 'Stop & Add to Playlist' : 'Start Recording'}
</button>
);
}
Latency Compensation​
When recording, there is typically a small delay between when audio enters the microphone and when it is captured. By default, useIntegratedRecording automatically compensates for this delay using AudioContext.outputLatency plus Tone.js scheduler lookahead (~0.1 s).
:::tip Try it live The recording example has a Latency (s) input in its toolbar — enter a value in seconds and record to apply the override, or leave it empty for the auto-computed value. :::
Overriding with a Measured Latency​
If you have measured the actual round-trip latency of your audio interface (for example via a loopback test), you can supply that value directly with the latencyOffset option (in seconds):
const recording = useIntegratedRecording(tracks, setTracks, selectedTrackId, {
currentTime,
latencyOffset: 0.023, // 23 ms externally-measured round-trip latency
});
Semantics:
| Value | Behaviour |
|---|---|
| Omitted | Auto-computed value (outputLatency + Tone.js lookAhead) |
0 | Compensation disabled — clip starts at the recorded sample |
| Positive number | Absolute replacement for the auto-computed value (seconds) |
| Negative or non-finite | Treated as 0 (no compensation) |
latencyOffset is an absolute replacement, not an additive offset. Setting it to 0.023 always skips exactly 23 ms of captured audio, regardless of the hardware's outputLatency.
Keeping Preview and Clip in Sync​
For the live waveform preview to match the finalized clip, pass the same value to both useIntegratedRecording and the recordingState.latencyOffset prop on <Waveform>:
const LATENCY = 0.023; // your measured latency, in seconds
const { isRecording, recordingPeaks, ... } =
useIntegratedRecording(tracks, setTracks, selectedTrackId, {
currentTime,
latencyOffset: LATENCY,
});
<Waveform
recordingState={isRecording ? {
isRecording,
trackId: selectedTrackId,
// startSample/durationSamples are derived by the consumer — see the full
// recording example. Typically: startSample = recordingStartSample (the
// captured record-start position) and
// durationSamples = Math.floor(duration * sampleRate).
startSample,
durationSamples,
peaks: recordingPeaks,
bits: 16,
latencyOffset: LATENCY, // must match the value above
} : undefined}
/>
If latencyOffset is omitted from recordingState, the preview uses the same auto-computed value as useIntegratedRecording.
Download Recorded Audio​
Recordings are captured as raw PCM audio using an AudioWorklet. After recording, the audio is available as an AudioBuffer which can be exported to WAV format.
Use the ExportWavButton component or the useExportWav hook to download recordings:
import { ExportWavButton } from '@waveform-playlist/browser/tone';
function RecordingWithExport() {
return (
<WaveformPlaylistProvider tracks={tracks}>
{/* Recording controls... */}
<ExportWavButton
label="Download Recording"
filename="my-recording"
/>
<Waveform />
</WaveformPlaylistProvider>
);
}
Or use the hook for more control:
import { usePlaylistData } from '@waveform-playlist/browser';
import { useExportWav } from '@waveform-playlist/browser/tone';
function DownloadRecording() {
const { tracks, trackStates } = usePlaylistData();
const { exportWav, isExporting, progress } = useExportWav();
const handleDownload = async () => {
await exportWav(tracks, trackStates, {
filename: `recording-${Date.now()}`,
mode: 'master', // or 'individual' for separate stems
bitDepth: 16,
});
};
return (
<button onClick={handleDownload} disabled={isExporting || tracks.length === 0}>
{isExporting ? `Exporting ${Math.round(progress * 100)}%` : 'Download Recording'}
</button>
);
}
Recording Format​
Recordings are captured using an AudioWorklet that processes raw PCM audio samples directly from the microphone. This approach provides:
- Lossless quality - No compression artifacts during capture
- Sample-accurate timing - Precise synchronization with other tracks
- Real-time waveform - Live visualization as you record
- Seamless integration - Recorded audio is added directly as playlist tracks
The AudioWorklet runs in a separate thread to ensure smooth recording without blocking the main UI thread.
Export Options​
When exporting recordings, you can choose:
| Option | Description |
|---|---|
mode: 'master' | Export all tracks as a mixdown (channel count matches source) |
mode: 'individual' | Export a single track as a stem |
bitDepth: 16 | Standard CD quality (16-bit PCM) |
bitDepth: 32 | High resolution (32-bit float) |
applyEffects: true | Include fades and effects in export |
Complete Example​
This example uses useIntegratedRecording for automatic track management. Recorded audio is automatically added as a clip to the selected track:
import { useState } from 'react';
import {
WaveformPlaylistProvider,
Waveform,
PlayButton,
PauseButton,
StopButton,
usePlaybackAnimation,
} from '@waveform-playlist/browser';
import { ExportWavButton } from '@waveform-playlist/browser/tone';
import { useIntegratedRecording } from '@waveform-playlist/recording';
import { SegmentedVUMeter } from '@waveform-playlist/ui-components';
import { createTrack, type ClipTrack } from '@waveform-playlist/core';
function RecordingControls({
tracks,
setTracks,
selectedTrackId,
setSelectedTrackId,
}: {
tracks: ClipTrack[];
setTracks: (tracks: ClipTrack[]) => void;
selectedTrackId: string | null;
setSelectedTrackId: (id: string | null) => void;
}) {
const { currentTime } = usePlaybackAnimation();
const {
isRecording,
duration,
levels,
peakLevels,
hasPermission,
startRecording,
stopRecording,
requestMicAccess,
} = useIntegratedRecording(tracks, setTracks, selectedTrackId, { currentTime });
const handleAddTrack = () => {
const newTrack = createTrack({
name: `Track ${tracks.length + 1}`,
clips: [],
});
setTracks([...tracks, newTrack]);
setSelectedTrackId(newTrack.id);
};
const handleRecord = async () => {
if (!hasPermission) {
await requestMicAccess();
}
if (isRecording) {
stopRecording();
} else {
// Create track if none selected
if (!selectedTrackId) {
handleAddTrack();
}
startRecording();
}
};
return (
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<button onClick={handleRecord}>
{isRecording ? `Stop (${duration.toFixed(1)}s)` : 'Record'}
</button>
<button onClick={handleAddTrack}>+ Add Track</button>
{hasPermission && (
<SegmentedVUMeter levels={levels} peakLevels={peakLevels} />
)}
</div>
);
}
function RecordingExample() {
const [tracks, setTracks] = useState<ClipTrack[]>([]);
const [selectedTrackId, setSelectedTrackId] = useState<string | null>(null);
return (
<WaveformPlaylistProvider
tracks={tracks}
samplesPerPixel={1024}
waveHeight={100}
timescale
controls={{ show: true, width: 200 }}
>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
<PlayButton />
<PauseButton />
<StopButton />
<ExportWavButton label="Export" filename="recording" />
</div>
<RecordingControls
tracks={tracks}
setTracks={setTracks}
selectedTrackId={selectedTrackId}
setSelectedTrackId={setSelectedTrackId}
/>
<Waveform />
</WaveformPlaylistProvider>
);
}
export default RecordingExample;
AudioWorklet Setup​
Recording uses AudioWorklets for low-latency, sample-accurate audio capture and metering. The worklet files need to be served as separate JavaScript files that the browser loads at runtime.
How It Works​
The @waveform-playlist/worklets package (auto-installed with @waveform-playlist/recording) includes pre-built worklet files at:
node_modules/@waveform-playlist/worklets/dist/worklet/recording-processor.worklet.js
node_modules/@waveform-playlist/worklets/dist/worklet/meter-processor.worklet.js
The recording and metering hooks automatically resolve these files using import.meta.url, which works out of the box with modern bundlers that support ES modules.
Bundler Configuration​
Vite​
Vite handles import.meta.url natively. No additional configuration needed.
Webpack 5​
Add the worklet to your asset configuration:
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.worklet\.(js|mjs)$/,
type: 'asset/resource',
generator: {
filename: 'worklets/[name][ext]',
},
},
],
},
};
Next.js​
Next.js uses Webpack under the hood. Add to next.config.js:
// next.config.js
module.exports = {
webpack: (config) => {
config.module.rules.push({
test: /\.worklet\.(js|mjs)$/,
type: 'asset/resource',
generator: {
filename: 'static/worklets/[name][ext]',
},
});
return config;
},
};
Troubleshooting​
"Failed to load recording processor" error:
This usually means the worklet file couldn't be loaded. Check:
- Network tab: Look for a 404 on
recording-processor.worklet.js - CORS: The worklet must be served from the same origin or with proper CORS headers
- HTTPS: AudioWorklet requires a secure context (HTTPS or localhost)
"Module already registered" warning:
This is harmless and occurs when the worklet is loaded multiple times (e.g., hot reloading during development). The hook handles this gracefully.
Integrating third-party AudioWorklet code​
Recording (and the rest of @waveform-playlist/playout) runs on a shared global audio context from getGlobalContext() / getGlobalAudioContext(). That context is Tone.js's, and its rawContext is always a standardized-audio-context ponyfill — not a native BaseAudioContext / AudioContext. This is deliberate: the ponyfill normalizes cross-browser Web Audio differences (e.g. Firefox AudioWorkletNode / AudioParam).
Two consequences for third-party Web Audio code you run alongside the playlist:
getGlobalAudioContext() instanceof window.AudioContextisfalse.- The native
AudioWorkletNodeconstructor rejects it —new AudioWorkletNode(getGlobalAudioContext(), 'my-processor')throwsTypeError: parameter 1 is not of type 'BaseAudioContext'(same in Chrome and Firefox).
So don't hardcode the native constructor. Use the dependency-injection pattern from chrisguttandin/limiter-audio-worklet — accept the AudioWorkletNode constructor and the module loader as parameters — or import AudioWorkletNode from standardized-audio-context, so your code works on a native or ponyfilled context. That is exactly what @waveform-playlist/worklets does:
// caller supplies addModule for whichever context type is in use
await addRecordingWorkletModule((url) => ctx.audioWorklet.addModule(url));
If you genuinely need a native AudioContext, own it yourself via the web-components surface (@dawcore/*), which uses native Web Audio rather than Tone.
Browser Compatibility​
Recording requires:
getUserMediaAPI for microphone accessAudioWorkletAPI for sample-accurate recording- Secure context (HTTPS or localhost)
| Browser | Support |
|---|---|
| Chrome | Full support (66+) |
| Firefox | Full support (76+) |
| Safari | Safari 14.1+ |
| Edge | Full support (79+) |
Next Steps​
- Annotations - Add annotations to recordings
- Track Management - Manage recorded tracks