methods being called on Audio.Sound() after I've unload it and moved screen

Viewed 1190

I'm extending the functionality of a timer component to add a beep sound for the last 3 seconds of the timer. That works fine, the issue is after all is said and done. I'm doing the following:

  1. Wrapping the timer component on a new functional component
  2. Initialise the Audio.Sound() in the body of the component
  3. Using useEffect to load the sound initially
  4. On each timer event I check if I should play the sound and play it using replayAsync
  5. On the useEffect cleanup I unload the sound object with unloadAsync

A second or so after the last beep has played and I've navigated to the following screen, I get an error which I'll past below in full. It seems there's a seeking operation being called on my sound object by the expo-av library, but my component is no longer there:

[Unhandled promise rejection: Error: Seeking interrupted.]

I've tried the following with no success:

  • Make the calls to loadAsync and unloadAsync await calls
  • Tried to setOnPlaybakStatusUpdate to null to try and prevent statusUpdate calls
  • I've even tried to not unload the soudn via unloadAsync

My code is:

import React from 'react'
import CountDown from 'react-native-countdown-component'
import { Audio } from 'expo-av'
const BEEP_START = 3

const CountDownBeep = (props) => {
  console.log('Sound Created')
  const beepSound = new Audio.Sound()

  React.useEffect(() => {
      async function loadSound() {
          console.log("Sound Initialized")
          await beepSound.loadAsync(require('../assets/sounds/beep.wav'), {
              shouldPlay: false,
              isLooping: false,
          })
          // This is not by design, just one of my attempts to get rid of the error
          beepSound.setOnPlaybackStatusUpdate()
      }

      loadSound()

      // Cleanup, tried with async and without
      return async () => {
          console.log('Sound destroyed')
          await beepSound.unloadAsync()
      }
  })

  const countDownTimerChangedHandler = (timeLeft) => {

      // This works fine
      if (timeLeft <= BEEP_START + 1 && timeLeft > 0) {
          console.log('Sound Played:', timeLeft)
          beepSound.replayAsync()
      }
  }

  return (
      <CountDown
          {...props}
          onChange={(timeLeft) => countDownTimerChangedHandler(timeLeft)}
      />
  )
}

export default CountDownBeep

The functionality is 100%, but after a few ms or a second or two after I navigate away to the next screen I get the following error:

[Unhandled promise rejection: Error: Seeking interrupted.]
- node_modules/react-native/Libraries/BatchedBridge/NativeModules.js:103:50 in promiseMethodWrapper
- node_modules/@unimodules/react-native-adapter/build/NativeModulesProxy.native.js:15:23 in moduleName.methodInfo.name
- node_modules/expo-av/build/Audio/Sound.js:138:24 in replayAsync
- node_modules/expo-av/build/Audio/Sound.js:5:33 in <anonymous>
- node_modules/regenerator-runtime/runtime.js:63:36 in tryCatch
- node_modules/regenerator-runtime/runtime.js:293:29 in invoke
- node_modules/regenerator-runtime/runtime.js:63:36 in tryCatch
- node_modules/regenerator-runtime/runtime.js:154:27 in invoke
- node_modules/regenerator-runtime/runtime.js:189:16 in PromiseImpl$argument_0
- node_modules/promise/setimmediate/core.js:45:6 in tryCallTwo
- node_modules/promise/setimmediate/core.js:200:22 in doResolve
- node_modules/promise/setimmediate/core.js:66:11 in Promise
- node_modules/regenerator-runtime/runtime.js:188:15 in callInvokeWithMethodAndArg
- node_modules/regenerator-runtime/runtime.js:211:38 in enqueue
- node_modules/regenerator-runtime/runtime.js:238:8 in exports.async
- node_modules/expo-av/build/Audio/Sound.js:5:33 in <anonymous>
- node_modules/expo-av/build/Audio/Sound.js:5:33 in <anonymous>
- node_modules/regenerator-runtime/runtime.js:63:36 in tryCatch
- node_modules/regenerator-runtime/runtime.js:293:29 in invoke
- node_modules/regenerator-runtime/runtime.js:63:36 in tryCatch
- node_modules/regenerator-runtime/runtime.js:154:27 in invoke
- node_modules/regenerator-runtime/runtime.js:189:16 in PromiseImpl$argument_0
- node_modules/promise/setimmediate/core.js:45:6 in tryCallTwo
- node_modules/promise/setimmediate/core.js:200:22 in doResolve
- node_modules/promise/setimmediate/core.js:66:11 in Promise
- node_modules/regenerator-runtime/runtime.js:188:15 in callInvokeWithMethodAndArg
- node_modules/regenerator-runtime/runtime.js:211:38 in enqueue
- node_modules/regenerator-runtime/runtime.js:238:8 in exports.async
- node_modules/expo-av/build/Audio/Sound.js:5:33 in <anonymous>
* components/CountDownBeep.js:31:24 in countDownTimerChangedHandler
* components/CountDownBeep.js:39:22 in CountDown.props.onChange
- node_modules/react-native-countdown-component/index.js:115:21 in CountDown#updateTimer
- node_modules/react-native/Libraries/Core/Timers/JSTimers.js:135:14 in _callTimer
- node_modules/react-native/Libraries/Core/Timers/JSTimers.js:387:16 in callTimers
- node_modules/react-native/Libraries/BatchedBridge/MessageQueue.js:425:19 in __callFunction
- node_modules/react-native/Libraries/BatchedBridge/MessageQueue.js:112:6 in __guard$argument_0
- node_modules/react-native/Libraries/BatchedBridge/MessageQueue.js:373:10 in __guard
- node_modules/react-native/Libraries/BatchedBridge/MessageQueue.js:111:4 in callFunctionReturnFlushedQueue
* [native code]:null in callFunctionReturnFlushedQueue
2 Answers

I think I've fixed it. The problem is not with the Sound API or expo-av. The issue seems to be how the timer component in question deals with callbacks. I had a suspicion, that since the next screen also has a component, that somehow the component is firing the new callback into the old component. I've added a unique ID to each instance of my component and I stopped getting errors:

 <CountDown
            {...props}
            id={new Date().toTimeString()}
            onChange={(timeLeft) => countDownTimerChangedHandler(timeLeft)}
        />

NOTE: As such I believe this question and answer are only relevant if you're using https://github.com/talalmajali/react-native-countdown-component.

Thank you for reading.

The other solution did not work for me. What I've tried that successfully got rid of this seeking interrupted error was is loading all of the sounds you need for a particular screen in componentDidMount. Then you can use them as you wish throughout the file.

    // This would be a function in your component class
    componentDidMount = () => {
        this.loadSounds();
   };
// You can put SoundUtils in any file!
    export const SoundUtils = {
        loadSound: async (requiredSound: AVPlaybackSource) => {
            try {
                const { sound } = await Audio.Sound.createAsync(requiredSound, {
                shouldPlay: false,
                progressUpdateIntervalMillis: 1, //It doesn't need to be this low
            });
                return sound;
            } catch (error) {
                console.log(`Sound not loaded error: ${error}`);
            }
        },
    } 
    // This would be a function in your component class
    loadsounds = () => {
        const workoutStartSound = await SoundUtils.loadSound(require("../assets/sounds/workoutStart.wav"));
        const timeToWorkoutSound = await SoundUtils.loadSound(require("../assets/sounds/timeToWork.mp3"));
    // Now to make sure you're only working with an "accepted promise"
    if (workoutStartSound !== undefined) {
      // workoutStartSound is a property of the Component class and is of type Audio.Sound
      this.workoutStartSound = workoutStartSound;
    } else {
      console.log(`Workout Start sound was not set`);
    }
    if (timeToWorkoutSound !== undefined) {
      this.timeToWorkoutSound = timeToWorkoutSound;
    } else {
      console.log(`Time to Workout sound was not set`);
    }   

Having the sounds pre-loaded will be the best way to avoid interrupting the seek!

    playSound = async () => {
        try {
            await this.workoutStartSound.playFromPositionAsync(0);
            // await this.timeToWorkoutSound.playFromPositionAsync(0);
        } catch (error) {
            console.log(`Error in start timer - ${error}`);
        }
    };
Related