Voice recordings and audio playback
A voice recording is a message the user records in the composer. This page covers recording, how a recording is turned into an attachment and sent, and how the SDK plays voice recordings and audio files.
What users get
The user's composer is empty, so a microphone button sits where the send button would be. They hold it, and the first time the device asks for the microphone. While they hold it, the composer shows the recording time and "Slide to Cancel". Sliding left cancels the recording. Sliding up locks it, so they can let go: the composer then shows a live waveform, with buttons to delete the recording, stop it and send it. After they stop it, they can listen to it before they send or delete it.
Releasing the button without locking it finishes the recording. By default the recording then waits above the input with the text and files they add, and goes out when they send. A tap on the microphone instead of a hold shows "Hold to record. Release to save."
In the message list, the recording plays in a player with a play button, the waveform, the time left and a speed button that cycles x1.0, x1.5 and x2.0. Dragging along the waveform seeks. An audio file plays in the same player, with a progress bar in place of the waveform. In the channel list and in a reply that quotes it, a recording shows as "Voice message (0:12)".
Starting a recording or an audio file pauses the one that was playing in the channel, so only one plays at a time. Starting to record pauses it too. Closing the channel screen stops its audio.
How it works across the layers
- State. A voice recording is an attachment of type
voiceRecording. The composer uploads and sends it like any other attachment, so the upload settings and a custom upload function apply to it. See Attachments and uploads. - UI.
MessageComposerrecords through the audio package your app installs, and turns the recording into an attachment.AudioAttachmentplays it in the message list. Playback runs through a pool of audio players thatChannelandThreadeach provide. - Layout. Not involved, except that a channel and a thread each scope their own playback.
The attachment
A voice recording carries these fields. The metadata is under custom, like every attachment's own data:
| Field | Holds |
|---|---|
type |
always "voiceRecording" |
asset_url |
the uploaded file |
title |
the file name, such as audio_recording_2026-10-25T10_12_03_120Z.aac |
custom.duration |
the length in seconds |
custom.file_size |
the size in bytes. The SDK's own recordings carry 0. |
custom.mime_type |
the file type, audio/aac for the SDK's recordings |
custom.waveform_data |
up to 100 amplitudes between 0 and 1, drawn as the waveform. Without it, a progress bar shows. |
Recording
- With
audioRecordingEnabledonChannelorThread, the composer shows the record button,StartAudioRecordingButton, in place of the send button while it has no text and no attachments. A message being edited, an active command and a slow-mode cooldown each show their own button instead. - A press of at least
asyncMessagesMinimumPressDuration(500 ms) starts recording. The audio package asks for the microphone the first time. When access was denied, a notification asks the user to allow it in the settings, with a button that opens them. - While the user holds the button,
AudioRecordertakes the input's place, andAudioRecordingLockIndicatorshows above it. Releasing the button after sliding it to the left by more than half ofasyncMessagesSlideToCancelDistance(75 points) cancels the recording. Releasing it after sliding it up by more than half ofasyncMessagesLockDistance(50 points) locks it. A locked recording showsAudioRecordingInProgresswith its waveform, and after it's stopped,AudioRecordingPreview, which plays it. - The recording is AAC. While it runs, the audio level is sampled for the waveform, and when it completes, the samples are reduced to at most 100.
- Releasing the button, or the send button of a locked recording, completes it. A recording shorter than 300 ms is thrown away. The SDK turns the recording into a
voiceRecordingattachment and uploads it with the composer's attachment manager. The recording waits in the composer, shown byAudioAttachmentUploadPreview, and goes out with the text and files when the user sends. - With
audioRecordingSendOnComplete, the recording is sent as a message of its own as soon as it completes, without waiting in the composer.
useMessageInputContext() exposes the recorder to your own components: audioRecorderManager, whose state store holds status, duration, waveformData and micLocked, and the startVoiceRecording(), stopVoiceRecording(), deleteVoiceRecording() and uploadVoiceRecording(sendOnComplete) functions.
Playback
Channel and Thread each provide an audio player pool through AudioPlayerProvider. A component asks the pool for a player with useAudioPlayer({ uri, requester, type, duration, ... }). The player's id is built from requester, uri and id, and the same id gives back the same player, so a message scrolled out of the list and back keeps its progress.
The player's state is a store, audioPlayer.state, and its methods control playback: AudioPlayer.play(), AudioPlayer.pause(), AudioPlayer.toggle(), AudioPlayer.stop(), AudioPlayer.seek(positionInSeconds) and AudioPlayer.changePlaybackRate(), which moves to the next of its playbackRates. The state holds isPlaying, duration and position in milliseconds, progress from 0 to 1, currentPlaybackRate and playbackRates. The duration you pass to useAudioPlayer is in seconds. When a file can't be played or seeked in, the player publishes an error notification.
By default the pool plays one sound at a time. A player that starts pauses the one that was playing. useActiveAudioPlayer() returns the player that is playing, and null once it pauses. When the channel or thread screen unmounts, the pool stops and discards its players. A thread has a pool of its own, so a recording playing in the thread doesn't pause one playing in the channel.
Turning it on or off
Recording is off by default. Install an audio package, as Installation shows, with the microphone permission, and turn it on for each Channel and Thread you render:
import type { Channel as StreamChannel } from "stream-chat";
import {
Channel,
MessageComposer,
MessageList,
} from "stream-chat-react-native";
export const ChannelScreen = ({ channel }: { channel: StreamChannel }) => (
<Channel audioRecordingEnabled channel={channel}>
<MessageList />
<MessageComposer />
</Channel>
);A thread's Thread needs the prop too. Recordings are uploaded, so uploads must be allowed in the channel. See Turning it on or off on Attachments and uploads.
The record button checks neither of these. Without an audio package, it still shows, and holding it shows the notification about the microphone permission. When uploads aren't allowed, it still records, and the finished recording isn't added to the composer. Turn audioRecordingEnabled on only where both hold, for example where channel.messageComposer.attachmentManager.isUploadEnabled is true.
audioRecordingSendOnComplete sends the recording before its upload has finished, so it relies on sending with pending uploads. Turn messageComposer.attachments.pendingUploadsEnabled on with it, or the recording doesn't reach the message.
Playback needs only the audio package, because Channel and Thread provide it. Without an audio package, recordings and audio files render as file rows, which open the file's URL.
Playing several at once
allowConcurrentAudioPlayback on Channel and Thread lets several recordings play together. useActiveAudioPlayer() then always returns null.
Customizing
The recorder
Six override keys replace the recording UI: StartAudioRecordingButton, AudioRecorder, AudioRecordingInProgress, AudioRecordingLockIndicator, AudioRecordingPreview and AudioRecordingWaveform. AudioAttachmentUploadPreview replaces the recording's preview in the composer. A recorder of your own reads audioRecorderManager and calls the context's functions:
import { Pressable, StyleSheet, Text, View } from "react-native";
import {
useMessageInputContext,
useStateStore,
} from "stream-chat-react-native";
import type { ComponentOverrides } from "stream-chat-react-native";
const recorderSelector = ({
duration,
status,
}: {
duration: number;
status: string;
}) => ({
duration,
status,
});
const SimpleAudioRecorder = () => {
const {
audioRecorderManager,
audioRecordingSendOnComplete,
deleteVoiceRecording,
stopVoiceRecording,
uploadVoiceRecording,
} = useMessageInputContext();
const { duration, status } = useStateStore(
audioRecorderManager.state,
recorderSelector,
);
return (
<View style={styles.row}>
<Text>{Math.floor(duration / 1000)}s</Text>
<Pressable onPress={deleteVoiceRecording}>
<Text>Delete</Text>
</Pressable>
{status === "recording" ? (
<Pressable onPress={stopVoiceRecording}>
<Text>Stop</Text>
</Pressable>
) : null}
<Pressable
onPress={() => uploadVoiceRecording(audioRecordingSendOnComplete)}
>
<Text>Send</Text>
</Pressable>
</View>
);
};
// Pass to `WithComponents`, declared once at module scope.
export const overrides: ComponentOverrides = {
AudioRecorder: SimpleAudioRecorder,
};
const styles = StyleSheet.create({
row: { alignItems: "center", flexDirection: "row", gap: 16, padding: 8 },
});AudioRecorder renders while a recording runs. Before the recording is locked, the user's finger is still on the record button, so most apps keep the default and change only the locked state's parts. Message composer UI lists the composer's other override keys.
The recording settings come from the audio package. It records AAC, and samples the audio level for the waveform. To change the settings, pass overrideAudioRecordingConfiguration to registerNativeHandlers(). See Entry points and native handlers.
A player of your own
AudioAttachment is the override key for the player in the message list, for voice recordings and audio files alike. It receives the attachment as item, and the message. This one renders a single button with the seconds played, and adds a 3x speed:
import { Pressable, Text } from "react-native";
import { useAudioPlayer, useStateStore } from "stream-chat-react-native";
import type {
AudioAttachmentProps,
AudioPlayerState,
ComponentOverrides,
} from "stream-chat-react-native";
const playbackRates = [1, 1.5, 2, 3];
const playerSelector = ({
currentPlaybackRate,
isPlaying,
position,
}: AudioPlayerState) => ({
currentPlaybackRate,
isPlaying,
position,
});
const CompactAudioAttachment = ({ item, message }: AudioAttachmentProps) => {
const audioPlayer = useAudioPlayer({
duration: item.custom?.duration ?? 0,
mimeType: item.custom?.mime_type ?? "",
playbackRates,
requester: message?.id,
type: item.type,
uri: item.asset_url ?? "",
});
const { currentPlaybackRate, isPlaying, position } = useStateStore(
audioPlayer.state,
playerSelector,
);
return (
<Pressable
onLongPress={() => audioPlayer.changePlaybackRate()}
onPress={() => audioPlayer.toggle()}
>
<Text>
{isPlaying ? "Pause" : "Play"} {Math.round(position / 1000)}s x
{currentPlaybackRate}
</Text>
</Pressable>
);
};
// Pass to `WithComponents`, declared once at module scope.
export const overrides: ComponentOverrides = {
AudioAttachment: CompactAudioAttachment,
};A player keeps the playbackRates it was created with, so give the same rates wherever the same player is used. PlayPauseButton, WaveProgressBar, ProgressControl and SpeedSettingsButton are the SDK's own parts, if you want to build from them.
useAudioPlayer works under Channel, Thread or AudioPlayerProvider. Outside a channel screen, wrap your audio UI in <AudioPlayerProvider value={{ allowConcurrentAudioPlayback: false }}>. Changing allowConcurrentAudioPlayback creates a new pool and stops what was playing.
A player that stays visible
useActiveAudioPlayer() returns the player that is playing, so a bar at the top of the channel can show it after the user scrolls away from the message. Call it under Channel, Thread or AudioPlayerProvider.
import { Pressable, StyleSheet, Text, View } from "react-native";
import { useActiveAudioPlayer, useStateStore } from "stream-chat-react-native";
import type { AudioPlayer, AudioPlayerState } from "stream-chat-react-native";
const positionSelector = ({ duration, position }: AudioPlayerState) => ({
duration,
position,
});
const NowPlayingControls = ({ audioPlayer }: { audioPlayer: AudioPlayer }) => {
const { duration, position } = useStateStore(
audioPlayer.state,
positionSelector,
);
return (
<View style={styles.bar}>
<Text>
{Math.round(position / 1000)}s / {Math.round(duration / 1000)}s
</Text>
<Pressable onPress={() => audioPlayer.pause()}>
<Text>Pause</Text>
</Pressable>
</View>
);
};
export const NowPlaying = () => {
const audioPlayer = useActiveAudioPlayer();
return audioPlayer ? <NowPlayingControls audioPlayer={audioPlayer} /> : null;
};
const styles = StyleSheet.create({
bar: { flexDirection: "row", gap: 12, justifyContent: "center", padding: 8 },
});Render NowPlaying inside Channel, for example above the MessageList. It disappears when the player pauses, because the hook returns null then.
Text and translations
The quoted reply's and the channel preview's Voice message ({{duration}}) is messagePreview.voiceMessage.label, and the notifications above have keys under messageInput.audioRecorder and audioPlayer. See Localization.
Reference
- Audio recorder: the recorder's components and props.
- Attachments:
AudioAttachmentand the other renderers. - Channel:
audioRecordingEnabled,audioRecordingSendOnComplete,allowConcurrentAudioPlaybackand theasyncMessages*props. - Composer hooks:
useMessageInputContextanduseAttachmentManagerState. - Installation: the audio packages and the microphone permission.