Info:
This is release candidate documentation for Stream Chat React Native SDK v10. For the latest stable version, see the latest version (v9).

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. MessageComposer records through the audio package your app installs, and turns the recording into an attachment. AudioAttachment plays it in the message list. Playback runs through a pool of audio players that Channel and Thread each 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

  1. With audioRecordingEnabled on Channel or Thread, 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.
  2. 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.
  3. While the user holds the button, AudioRecorder takes the input's place, and AudioRecordingLockIndicator shows above it. Releasing the button after sliding it to the left by more than half of asyncMessagesSlideToCancelDistance (75 points) cancels the recording. Releasing it after sliding it up by more than half of asyncMessagesLockDistance (50 points) locks it. A locked recording shows AudioRecordingInProgress with its waveform, and after it's stopped, AudioRecordingPreview, which plays it.
  4. 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.
  5. 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 voiceRecording attachment and uploads it with the composer's attachment manager. The recording waits in the composer, shown by AudioAttachmentUploadPreview, and goes out with the text and files when the user sends.
  6. 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.

Warning:

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: AudioAttachment and the other renderers.
  • Channel: audioRecordingEnabled, audioRecordingSendOnComplete, allowConcurrentAudioPlayback and the asyncMessages* props.
  • Composer hooks: useMessageInputContext and useAttachmentManagerState.
  • Installation: the audio packages and the microphone permission.