Building an Audio Waveform in Expo Audio: iOS & Android
Ditch the deprecated expo-av API. Build a highly fluid, live-metered audio track recorder with custom visual buffers.

1. The Elephant in the Room: expo-av vs. expo-audio
If you've been looking up React Native audio tutorials, you've probably hit a wall of guides using `expo-av`. Here is the truth: for modern audio recording and visualization, `expo-av` is outdated. `expo-audio` is the modern standard provided by Expo. It gives us granular control and features that the older library struggles with, specifically:
- Live decibel metering out-of-the-box (crucial for our waveform visualizer).
- Modern hooks like `useAudioRecorder` that drastically reduce boilerplate.
- Better native performance for low-latency audio streams.
2. Getting Started: Installation
Let's ditch the old methods and bring in the right tool for the job. Run this in your terminal to install the modern `expo-audio` library:
npx expo install expo-audio3. Configuring Permissions in app.json
Before we can visualize audio, we need permission to capture it. We handle this inside the app.json file. The expo-audio plugin simplifies this process across both iOS and Android platforms.
{
"expo": {
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#000000"
}
}
],
[
"expo-audio",
{
"microphonePermission": "Allow $(PRODUCT_NAME) to access your microphone."
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
}
}
}- The microphonePermission string is exactly what iOS users will see when the system prompt appears.
- For Android, the plugin automatically injects the necessary microphone entries into your manifest layout.
4. The Engine: Building a Custom Recorder Hook
Instead of cluttering our UI components with audio logic, we extract the brains of the operation into a custom hook. This hook handles permissions, starts/stops recording, and safely captures decibel readings.
import { useEffect, useRef, useState } from "react";
import { useAudioRecorder, useAudioRecorderState, RecordingPresets, AudioModule, setAudioModeAsync } from "expo-audio";
import { Alert } from "react-native";
export default function useAudioRecorderHook() {
const [audioUri, setAudioUri] = useState<string | null>(null);
const recorder = useAudioRecorder({
...RecordingPresets.HIGH_QUALITY,
isMeteringEnabled: true,
});
const recorderState = useAudioRecorderState(recorder);
const latestDecibel = useRef<number | null>(null);
useEffect(() => {
if (recorderState.metering != null) {
latestDecibel.current = recorderState.metering;
}
if (!recorderState.isRecording && recorderState.uri) {
setAudioUri(recorderState.uri);
}
}, [recorderState.metering, recorderState.isRecording, recorderState.uri]);
const startOrStopRecording = async () => {
try {
const permission = await AudioModule.requestRecordingPermissionsAsync();
if (!permission.granted) {
Alert.alert("Permission required");
return;
}
await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true });
if (recorderState.isRecording) {
await recorder.stop();
} else {
await recorder.prepareToRecordAsync();
recorder.record();
}
} catch (e) {
console.error("Failed to start or stop recording:", e);
Alert.alert("Recording Error", "An error occurred while managing the recording.");
}
};
return {
recordingInProgress: recorderState.isRecording,
currentDecibel: recorderState.metering ?? null,
audioUri,
startOrStopRecording,
latestDecibel,
};
}- isMeteringEnabled: true is the magic switch that gives us real-time visual streams.
- Performance Secret: We store the decibel reading inside a `useRef`. Because decibels update dozens of times per second, state would cause endless, laggy re-renders.
- Clean Cleanup: The state only locks in the final URI when the recording has successfully finalized.
5. The Visuals: Building the WhatsApp-Style Waveform
Now for the visual payoff. We want a smooth, scrolling set of bars that react to voice input. To make it look natural, we apply a mathematical normalization and a slight random variance (the 'WhatsApp wiggle').
import { useEffect, useState } from "react";
import { View } from "react-native";
export default function WaveformDisplay({
recordingInProgress,
latestDecibel,
}: {
recordingInProgress: boolean;
latestDecibel: React.MutableRefObject<number | null>;
}) {
const [waveformHeights, setWaveformHeights] = useState<number[]>([]);
const maxBars = 50;
useEffect(() => {
if (!recordingInProgress) return;
setWaveformHeights([]);
let waveformBuffer: number[] = [];
const interval = setInterval(() => {
if (latestDecibel.current != null) {
const normalized = Math.max(0, Math.min(1, (latestDecibel.current + 60) / 60));
const variation = 0.6 + Math.random() * 0.1; // WhatsApp wiggle
const height = normalized * 40 * variation;
waveformBuffer.push(height);
if (waveformBuffer.length > maxBars) waveformBuffer.shift();
setWaveformHeights([...waveformBuffer]);
}
}, 120);
return () => clearInterval(interval);
}, [recordingInProgress]);
return (
<View style={{
height: 60,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 2,
width: "100%",
marginBottom: 20,
}}>
{waveformHeights.map((height, index) => (
<View
key={index}
style={{
width: 4,
height: Math.max(4, height * 2),
backgroundColor: "#007AFF",
borderRadius: 2,
}}
/>
))}
</View>
);
}- Normalization: Raw decibel values range from -60 (silence) to 0 (max). The formula maps this cleanly into a `0.0` to `1.0` scale.
- The Buffer Array: Pushing into an array and using `.shift()` caps it at 50 bars, creating that infinite scrolling illusion.
- Organic Variation: Multiplying by a small random range produces the subtle flickering animation found in modern voice message layouts.
6. Stitching It Together in the UI
Because we isolated our logic and visuals into clean subsystems, your master implementation layout remains simple, beautiful, and maintainable.
import { Text, TouchableOpacity, View } from "react-native";
import useAudioRecorderHook from "./useAudioRecorderHook";
import WaveformDisplay from "./waveForm";
export default function AudioRecorderWithWaveform() {
const { recordingInProgress, currentDecibel, startOrStopRecording, latestDecibel } =
useAudioRecorderHook();
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center", padding: 20 }}>
<Text style={{ marginBottom: 20, fontSize: 18, fontWeight: "bold" }}>Expo-Audio/WaveForm</Text>
<WaveformDisplay recordingInProgress={recordingInProgress} latestDecibel={latestDecibel} />
{currentDecibel != null && <Text style={{ marginBottom: 10 }}>{currentDecibel.toFixed(1)} dB</Text>}
<TouchableOpacity
onPress={startOrStopRecording}
style={{
padding: 10,
backgroundColor: recordingInProgress ? "#FF3B30" : "#007AFF",
borderRadius: 6,
}}
>
<Text style={{ color: "white" }}>{recordingInProgress ? "Stop" : "Record"}</Text>
</TouchableOpacity>
</View>
);
}7. Why This Architecture Wins
- Separation of Concerns: The engine hook, visual components, and dashboard maintain cleanly defined boundaries.
- No Laggy Overhead: Relying on references for high-frequency polling streams leaves user frames fully fluid.
- Future-Proof Execution: Migrating to modern framework modules frees you from deprecation errors completely.
Wrapping Up
And that is it! You now have a smooth, responsive audio recorder with a cool waveform visualizer built on the right APIs. Using a fixed buffer loop keeps your production workspace clean, fast, and light.