Voice recording and playback, as well as canceling recording by swiping to the right with animation and changing the icon to React Native

Hello everyone! My name is Hatam. I work for Neti. Once I was a website designer, but I wanted to develop further. So I mastered React and learned how to make web applications, and then decided to try my hand at mobile development. In this article, I share examples of solutions that I came up with while working on a problem for one of my clients. Hope my experience will be useful to someone.

Project

A mobile application designed to quickly send messages about problems and dangerous factors for the life and health of people on the territory of a manufacturing enterprise.

Users: company employees, contractors, and business guests.

Platforms: Android, IOS.

Technology stack: React Native, MobX.

Task

Opportunities that needed to be implemented:

  • Voice recording;

  • Cancel recording by swiping to the right with animation and changing the icon;

  • Play the recorded sound.

Also, the recorded voice had to be played in the admin panel in the browser.

This is how it should work:

Solution

I started looking for the library. Of the options found, react-native-audio-recorder-player turned out to be the most popular. Its main advantage is the “2 in 1” principle, that is, recording and playback of recorded audio.

Made two components: AudioRecorder and AudioPlayer.

Let’s start with AudioRecorder.tsx. I found the library, but it does not know how to cancel the recording by swipe. This had to be done independently.

After a long search and thought, an idea came to my mind: what if this swipe is made a slider? Why not: the slider has a state that allows you to smoothly animate the desired element and register the cancellation at a specific position. But to make the button we need, the slider must be non-standard. Therefore, I connected the react-native-slider-custom library. Hidden the slider bar, made a “record / cancel” button with dynamic icons, made absolute positioning over two blocks: text and recording duration. Everything is ready, the idea worked 😎

AudioRecorder.tsx

import React, { useEffect, useState } from 'react';
import { PermissionsAndroid, Platform, StyleSheet, Text, ToastAndroid, View } from 'react-native';
import { observer } from 'mobx-react-lite';
import Slider from 'react-native-slider-custom';
import {
  AudioEncoderAndroidType,
  AudioSet,
  AudioSourceAndroidType,
  AVEncoderAudioQualityIOSType,
  AVEncodingOption,
} from 'react-native-audio-recorder-player';
 
import { Colors } from '../../../styles/Colors';
import MicroPhoneIcon from '../../svg/MicroPhoneIcon';
import { TextStyle } from '../../../styles/TextStyle';
import RecordingIcon from '../../svg/RecordingIcon';
import TrashIcon from '../../svg/TrashIcon';
import { playerStyles } from './playerStyles';
import { useStores } from '../../../hooks/use-stores';
 
const CANCEL_RECORDING_SLIDER_VALUE = 0.8;
const MAX_AUDIO_DURATION = 180000; // ms = 180000=3m
 
interface IAudioRecorderProps {}
 
const AudioRecorder: React.FC<IAudioRecorderProps> = observer(({}) => {
  const { audioRecPlayStore: store } = useStores();
  const [androidGranted, setAndroidGranted] = useState(false);
  const [recordSlidingValue, setRecordSlidingValue] = useState(0);
  const [recordTime, setRecordTime] = useState('00:00:00');
 
  // Effects
  useEffect(() => {
    (async () => {
      if (Platform.OS === 'android') {
        const hasPermissionWrite = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE);
        const hasPermissionRecord = await PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.RECORD_AUDIO);
 
        if (hasPermissionRecord && hasPermissionWrite) {
          setAndroidGranted(true);
        } else {
          setAndroidGranted(false);
        }
      } else {
        setAndroidGranted(true);
      }
    })();
  }, []);
 
  // Handlers
  const handleSlidingStart = () => {
    onStartRecord();
  };
 
  const handleSlidingChange = (value: number) => {
    setRecordSlidingValue(value);
  };
 
  const handleSlidingComplete = (value: number) => {
    onStopRecord(value);
  };
 
  // Actions
  const onStartRecord = async () => {
    if (!androidGranted) {
      if (Platform.OS === 'android') {
        const granted = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.RECORD_AUDIO);
 
        if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
          ToastAndroid.show('Использование микрофона запрещена', ToastAndroid.LONG);
          return false;
        }
      }
 
      if (Platform.OS === 'android') {
        const granted = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE);
 
        if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
          ToastAndroid.show('Запись в хранилише запрещено', ToastAndroid.LONG);
          return false;
        }
      }
 
      setAndroidGranted(true);
    } else {
      const path = Platform.select({
        ios: 'akkerman_voice_message.m4a',
        android: 'sdcard/akkerman_voice_message.mp4',
      });
 
      const audioSet: AudioSet = {
        AudioEncoderAndroid: AudioEncoderAndroidType.AAC,
        AudioSourceAndroid: AudioSourceAndroidType.MIC,
        AVEncoderAudioQualityKeyIOS: AVEncoderAudioQualityIOSType.high,
        AVNumberOfChannelsKeyIOS: 2,
        AVFormatIDKeyIOS: AVEncodingOption.aac,
      };
 
      await store.audioRecorderPlayer.startRecorder(path, audioSet);
 
      store.setIsRecording(true);
      store.audioRecorderPlayer.addRecordBackListener((e: any) => {
        setRecordTime(store.audioRecorderPlayer.mmssss(Math.floor(e.current_position)));
 
        // Stop and save recording
        if (e.current_position > MAX_AUDIO_DURATION) {
          onStopRecord(0);
        }
 
        return;
      });
    }
 
    return;
  };
 
  const onStopRecord = async (value: number) => {
    const result = await store.audioRecorderPlayer.stopRecorder();
 
    setRecordSlidingValue(0);
    store.setIsRecording(false);
    store.audioRecorderPlayer.removeRecordBackListener();
 
    if (value > CANCEL_RECORDING_SLIDER_VALUE) {
      // Cancel recording
      store.setAudio(null);
    } else {
      // Save recording
      if (!result.includes('stop')) {
        store.setAudio({ duration: recordTime, uri: result });
      }
    }
  };
 
  // Renders
  return (
    <View>
      <Slider
        value={recordSlidingValue}
        minimumTrackTintColor="transparent"
        maximumTrackTintColor="transparent"
        onSlidingStart={handleSlidingStart}
        onSlidingComplete={handleSlidingComplete}
        onValueChange={handleSlidingChange}
        customThumb={
          store.isRecording ? (
            <View style={styles.animatedIconsContainer}>
              <View style={[styles.animatedIconsBG, { opacity: recordSlidingValue }]} />
              <View style={[styles.animatedIconsWrap]}>
                <MicroPhoneIcon color={Colors.white} style={[styles.animatedIcon, { opacity: 1 - recordSlidingValue }]} />
                <TrashIcon style={[styles.animatedIcon, { opacity: recordSlidingValue > CANCEL_RECORDING_SLIDER_VALUE ? 1 : recordSlidingValue }]} />
              </View>
            </View>
          ) : (
            <MicroPhoneIcon />
          )
        }
        thumbStyle={[
          playerStyles.button,
          store.isRecording && playerStyles.buttonShadow,
          { backgroundColor: recordSlidingValue > CANCEL_RECORDING_SLIDER_VALUE ? Colors.error : Colors.primary },
        ]}
        style={styles.swipeableButton}
        animateTransitions
      />
 
      <View style={[styles.recorder, { opacity: 1 - recordSlidingValue }]}>
        <View style={{ flexDirection: 'row', alignItems: 'center' }}>
          <Text style={{ ...TextStyle.caption, color: Colors.whiteMedium, marginLeft: 40 + 8 }}>
            {store.isRecording ? 'Вправо для отмены' : 'Зажмите для записи'}
          </Text>
        </View>
 
        {store.isRecording && (
          <View style={{ flexDirection: 'row', alignItems: 'center' }}>
            <Text style={{ ...TextStyle.body1, marginRight: 10 }}>{recordTime}</Text>
            <RecordingIcon />
          </View>
        )}
      </View>
    </View>
  );
});
 
export default AudioRecorder;
 
const styles = StyleSheet.create({
  recorder: {
    height: 40,
    flexDirection: 'row',
    alignItems: 'center',
    justifyContent: 'space-between',
  },
  swipeableButton: {
    position: 'absolute',
    top: 0,
    left: 0,
    width: '100%',
    height: 40,
    zIndex: 1,
  },
  animatedIconsContainer: {
    width: '100%',
    height: '100%',
    alignItems: 'center',
    justifyContent: 'center',
  },
  animatedIconsBG: {
    position: 'absolute',
    top: 0,
    left: 0,
    bottom: 0,
    right: 0,
    backgroundColor: Colors.error,
  },
  animatedIconsWrap: {
    position: 'relative',
    width: 24,
    height: 24,
  },
  animatedIcon: {
    position: 'absolute',
    top: 0,
    left: 0,
    bottom: 0,
    right: 0,
  },
});

To implement the cancellation of the recording, I used the onSlidingComplete method of the slider library, which is triggered when the slider is released. I decided to make the cancellation position 80%. For convenience, I changed CANCEL_RECORDING_SLIDER_VALUE equal to 0.8, because the default slider works from 0 to 1.

When the record button (slider) is released, we call the handleSlidingComplete handler. It, in turn, calls the onStopRecord function, passing there the position of the slider at the time of release. In the onStopRecord function, depending on the position of the slider, we make a check: save the record or cancel the record. To determine what action to perform, we write the condition value> CANCEL_RECORDING_SLIDER_VALUE. If the slider values ​​are greater than 0.8 when the record button is released, we cancel the recording, if less, we save.

That’s all!

AudioPlayer.tsx

import React, { useCallback, useEffect, useState } from 'react';
import { ActivityIndicator, Platform, Pressable, StyleSheet, Text, View } from 'react-native';
import { observer } from 'mobx-react-lite';
import Slider from 'react-native-slider-custom';
import { useFocusEffect } from '@react-navigation/native';
 
import { useStores } from '../../../hooks/use-stores';
import { Colors } from '../../../styles/Colors';
import { TextStyle } from '../../../styles/TextStyle';
import PlayIcon from '../../svg/PlayIcon';
import PauseIcon from '../../svg/PauseIcon';
import { playerStyles } from './playerStyles';
 
interface IAudioPlayerProps {
  audio?: string;
}
 
const AudioPlayer: React.FC<IAudioPlayerProps> = observer(({ audio }) => {
  const { audioRecPlayStore: store } = useStores();
  const [currentPositionSec, setCurrentPositionSec] = useState(0);
  const [currentDurationSec, setCurrentDurationSec] = useState(0);
  const [playTime, setPlayTime] = useState('00:00:00');
  const [isPlaying, setIsPlaying] = useState(false);
  const [loading, setLoading] = useState(false);
 
  useFocusEffect(
    React.useCallback(() => {
      return () => {
        onStopPlay();
      };
    }, []),
  );
 
  useEffect(() => {
    return () => {
      onStopPlay();
    };
  }, []);
 
  // Actions
  const onPlayPause = () => {
    if (!loading) {
      if (isPlaying) {
        onPausePlay();
      } else {
        onStartPlay();
      }
    }
  };
 
  const onStartPlay = useCallback(async () => {
    const path = Platform.select({
      ios: audio ? audio : 'akkerman_voice_message.m4a',
      android: audio ? audio : 'sdcard/akkerman_voice_message.mp4',
    });
 
    setLoading(true);
    await store.audioRecorderPlayer.startPlayer(path);
 
    setLoading(false);
    setIsPlaying(true);
 
    store.audioRecorderPlayer.addPlayBackListener((e: any) => {
      if (e.current_position === e.duration) {
        setIsPlaying(false);
        onStopPlay();
      }
 
      setCurrentPositionSec(e.current_position);
      setCurrentDurationSec(e.duration);
      setPlayTime(store.audioRecorderPlayer.mmssss(Math.floor(e.current_position)));
 
      return;
    });
  }, [store.audioRecorderPlayer]);
 
  const onPausePlay = async () => {
    await store.audioRecorderPlayer.pausePlayer();
    setIsPlaying(false);
  };
 
  const onStopPlay = async () => {
    store.audioRecorderPlayer.stopPlayer();
    store.audioRecorderPlayer.removePlayBackListener();
  };
 
  // Renders
  return (
    <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
      <Pressable style={[playerStyles.button, { marginRight: 12 }]} onPress={onPlayPause}>
        {loading ? <ActivityIndicator size={24} color={Colors.black} /> : isPlaying ? <PauseIcon /> : <PlayIcon />}
      </Pressable>
      <View style={{ flex: 1 }}>
        <Slider
          maximumValue={Math.floor(currentDurationSec)}
          value={Math.floor(currentPositionSec)}
          minimumTrackTintColor={Colors.primary}
          maximumTrackTintColor={Colors.whiteMedium}
          thumbStyle={{ width: 12, height: 12, backgroundColor: Colors.primary }}
          style={{ height: 12 + 8 }}
          disabled
        />
        <Text style={{ ...TextStyle.caption, color: Colors.white }}>{playTime}</Text>
      </View>
    </View>
  );
});
 
export default AudioPlayer;

This is a regular custom audio player with some library differences.

For the recorder and player to work together, you must use the same instance of the player. Also, for good UX, it was necessary to turn off the scrolling of the “Risk message compose” screen during voice recording so that only the slider worked. Therefore, MobX made the AudioRecPlayStore.ts store.

AudioRecPlayStore.ts

import { makeAutoObservable } from 'mobx';
import AudioRecorderPlayer from 'react-native-audio-recorder-player';
 
import { Nullable } from '../../../types/CommonTypes';
import { IAudioObject } from './AudioRecPlay';
 
export class AudioRecPlayStore {
  isRecording = false;
 
  audioRecorderPlayer: AudioRecorderPlayer;
  audio: Nullable<IAudioObject> = null;
 
  constructor() {
    makeAutoObservable(this);
    this.audioRecorderPlayer = new AudioRecorderPlayer();
  }
 
  setIsRecording = (state: boolean) => {
    this.isRecording = state;
  };
 
  setAudio = (audio: Nullable<IAudioObject>) => {
    this.audio = audio;
  };
 
  clear = () => {
    this.isRecording = false;
    this.audio = null;
  };
}

The store has the following properties:

  • An instance of the audioRecorderPlayer for switching from recorder to player and vice versa;

  • The recording state isRecording to disable scrolling during recording.

<ScrollView scrollEnabled={!audioRecPlayStore.isRecording}>

The MessageAudioMessage.tsx component is responsible for rendering the recorder or player.

MessageAudioMessage.tsx

import React, { useEffect } from 'react';
import { Text, TouchableOpacity, View } from 'react-native';
import { observer } from 'mobx-react-lite';
 
import { useStores } from '../../../hooks/use-stores';
import InputLabel from '../../InputLabel';
import { messageStyles } from '../messageStyles';
import { Colors } from '../../../styles/Colors';
import { TextStyle } from '../../../styles/TextStyle';
import AudioRecorder from './AudioRecorder';
import AudioPlayer from './AudioPlayer';
import { FIleData } from '../../../modules/message/Message';
import { Nullable } from '../../../types/CommonTypes';
 
interface IMessageAudioMessageProps {
  onlyPlayer?: boolean;
  audio?: Nullable<FIleData>;
}
 
const MessageAudioMessage: React.FC<IMessageAudioMessageProps> = observer(({ onlyPlayer, audio }) => {
  const { audioRecPlayStore: store } = useStores();
 
  useEffect(() => {
    return () => {
      store.clear();
    };
  }, []);
 
  const handleRemoveAudio = () => {
    store.setAudio(null);
    store.audioRecorderPlayer.stopPlayer();
    store.audioRecorderPlayer.removePlayBackListener();
  };
 
  return (
    <View style={{ ...messageStyles.card, flexDirection: 'row', alignItems: 'center' }}>
      <View style={{ flex: 1 }}>
        <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 12 }}>
          <InputLabel label="Аудиосообщение" />
          {!onlyPlayer && !!store.audio && (
            <TouchableOpacity onPress={handleRemoveAudio} activeOpacity={0.8}>
              <Text style={{ ...TextStyle.subtitle, color: Colors.error, letterSpacing: 0 }}>Удалить</Text>
            </TouchableOpacity>
          )}
        </View>
        {onlyPlayer ? <AudioPlayer audio={audio?.fullUrl || ''} /> : !!store.audio ? <AudioPlayer /> : <AudioRecorder />}
      </View>
    </View>
  );
});
 
export default MessageAudioMessage;

There were problems playing the recorded voice on different platforms from different platforms: they were incompatible. The solution is in the recorder library documentation itself.

сonst audioSet: AudioSet = {
  AudioEncoderAndroid: AudioEncoderAndroidType.AAC,
  AudioSourceAndroid: AudioSourceAndroidType.MIC,
  AVEncoderAudioQualityKeyIOS: AVEncoderAudioQualityIOSType.high,
  AVNumberOfChannelsKeyIOS: 2,
  AVFormatIDKeyIOS: AVEncodingOption.aac,
};
 
await store.audioRecorderPlayer.startRecorder(path, audioSet);

Create an audioSet object with audio file recording settings and pass it as the second argument to the startRecorder method. These settings are cross-platform, that is, the recorded voice on Android / iOS is played on the web in the native html5 player and on Android / iOS.

Useful resources:

https://www.npmjs.com/package/react-native-audio-recorder-player

https://www.npmjs.com/package/react-native-slider-custom

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *