Skip to content

[Android] Crash in audioapi::AudioScheduledSourceNode::clearOnEndedCallback() #717

@cchafik

Description

@cchafik

we're getting crash reports in Google Play. The crash seems to occur during audio cleanup, specifically in AudioScheduledSourceNode::clearOnEndedCallback().

Stack trace

  #00  pc 0x0000000000190474  /data/app/.../libreact-native-audio-api.so (audioapi::AudioScheduledSourceNode::clearOnEndedCallback()+96)
#01  pc 0x00000000000dede8  /data/app/.../libreact-native-audio-api.so (audioapi::AudioBufferBaseSourceNodeHostObject::~AudioBufferBaseSourceNodeHostObject()+164)
#02  pc 0x00000000000ad658  /data/app/.../libhermes.so
#03  pc 0x00000000000b9b50  /data/app/.../libhermes.so
#04  pc 0x00000000001697c4  /data/app/.../libhermes.so
#05  pc 0x000000000016b1b0  /data/app/.../libhermes.so
#06  pc 0x000000000017014c  /data/app/.../libhermes.so
#07  pc 0x000000000016f26c  /data/app/.../libhermes.so
#08  pc 0x000000000016f130  /data/app/.../libhermes.so
#09  pc 0x00000000000ecd10  /apex/com.android.runtime/lib64/bionic/libc.so (__pthread_start(void*)+64)
#10  pc 0x000000000008c360  /apex/com.android.runtime/lib64/bionic/libc.so (__start_thread+64)

Our implementation:

here's how we manage playback and audio cleanup:

import { captureException } from "@sentry/react-native";
import { Asset } from "expo-asset";
import { useCallback, useEffect, useRef } from "react";
import { AppState } from "react-native";
import {
  AudioBuffer,
  AudioBufferSourceNode,
  AudioContext,
  GainNode,
} from "react-native-audio-api";
import { getSoundFile } from "./files";
import { Sound } from "./types";
import { useSoundVolume } from "./useSoundVolume";


export type PlaySoundOpts = {
  loop?: boolean;
  volume?: number;
};

export type FadeInOpts = {
  fadeDurationMs?: number;
  fromVolume?: number;
  loop?: boolean;
  intervalMs?: number;
  toVolume?: number;
};

export type FadeOutOpts = {
  fadeDurationMs?: number;
  fromVolume?: number;
  intervalMs?: number;
  stopAudioPlayer?: boolean;
  toVolume?: number;
  stopAfterFade?: boolean;
};

export function useSound(soundId: Sound) {
  const defaultVolume = useSoundVolume();

  const contextRef = useRef<AudioContext | null>(null);
  const bufferRef = useRef<AudioBuffer | null>(null);
  const sourceRef = useRef<AudioBufferSourceNode | null>(null);
  const gainNodeRef = useRef<GainNode | null>(null);

  const getContext = useCallback(() => {
    if (!contextRef.current) {
      contextRef.current = new AudioContext({ initSuspended: true });
    }
    return contextRef.current;
  }, []);

  const preloadSound = useCallback(async () => {
    try {
      const context = getContext();

      if (!bufferRef.current) {
        const assetId = getSoundFile(soundId);
        const [asset] = await Asset.loadAsync(assetId);

        const file = asset?.localUri ?? asset?.uri;

        if (!file) {
          throw new Error(`Failed to resolve URI for sound: ${soundId}`);
        }

        try {
          bufferRef.current = await context.decodeAudioDataSource(file);
        } catch (error) {
          logger.warn(
            "decodeAudioDataSource failed, falling back to fetch",
            error,
          );

          // fallback
          const resp = await fetch(file);
          const arrayBuffer = await resp.arrayBuffer();
          bufferRef.current = await context.decodeAudioData(arrayBuffer);
        }

        logger.verbose("decoded buffer", soundId);
      }
      return bufferRef.current;
    } catch (error) {
      captureException(
        new Error(`failed to preload sound file for: ${soundId}`),
        { contexts: { useSound: { error } } },
      );
      logger.error("preloadSound", soundId, error);
      return null;
    }
  }, [soundId, getContext]);

  const stopSound = useCallback(() => {
    const context = contextRef.current;
    if (sourceRef.current && context) {
      try {
        sourceRef.current.onEnded = null;
        sourceRef.current.stop(context.currentTime);
        sourceRef.current.disconnect();
      } catch (error) {
        logger.warn("stopSound error", error);
      } finally {
        sourceRef.current = null;
      }
    }
  }, []);

  const playSound = useCallback(
    async (opts: PlaySoundOpts = {}) => {
      const { loop = false, volume = defaultVolume } = opts;
      const context = getContext();
      const buffer = await preloadSound();
      if (!buffer) return;

      stopSound();

      if (context.state === "suspended") {
        await context.resume();
      }

      const sourceNode = context.createBufferSource();
      sourceNode.buffer = buffer;
      sourceNode.loop = loop;

      const gainNode = context.createGain();
      gainNode.gain.value = volume;

      sourceNode.connect(gainNode);
      gainNode.connect(context.destination);

      sourceRef.current = sourceNode;
      gainNodeRef.current = gainNode;

      sourceNode.onEnded = () => {
        logger.verbose("onended", soundId);
        sourceRef.current = null;
      };

      sourceNode.start(context.currentTime, 0);
      logger.verbose("playSound started", soundId, { loop, volume });
    },
    [preloadSound, stopSound, soundId, defaultVolume, getContext],
  );

  const fadeIn = useCallback(
    async (opts: FadeInOpts = {}) => {
      const {
        fadeDurationMs = 1000,
        fromVolume = 0,
        toVolume = 1,
        loop = false,
      } = opts;

      await preloadSound();
      await playSound({ loop, volume: fromVolume });

      if (!gainNodeRef.current) return;

      return new Promise<void>(resolve => {
        const ctx = getContext();
        const gain = gainNodeRef.current!.gain;
        const now = ctx.currentTime;

        gain.setValueAtTime(fromVolume, now);
        gain.linearRampToValueAtTime(toVolume, now + fadeDurationMs / 1000);

        setTimeout(resolve, fadeDurationMs);
      });
    },
    [preloadSound, playSound, getContext],
  );

  const fadeOut = useCallback(
    async (opts: FadeOutOpts = {}) => {
      const {
        fadeDurationMs = 1000,
        fromVolume = 1,
        toVolume = 0,
        stopAfterFade = true,
      } = opts;

      if (!gainNodeRef.current) return;

      const ctx = getContext();
      const gain = gainNodeRef.current.gain;
      const now = ctx.currentTime;

      gain.setValueAtTime(fromVolume, now);
      gain.linearRampToValueAtTime(toVolume, now + fadeDurationMs / 1000);

      return new Promise<void>(resolve => {
        setTimeout(() => {
          if (stopAfterFade) {
            stopSound();
          }
          resolve();
        }, fadeDurationMs);
      });
    },
    [stopSound, getContext],
  );

  const unloadSound = useCallback(() => {
    stopSound();
    if (gainNodeRef.current) {
      try {
        gainNodeRef.current.disconnect();
      } catch (err) {
        logger.error(err);
      }
      gainNodeRef.current = null;
    }
    bufferRef.current = null;
    logger.verbose("unloaded", soundId);
  }, [stopSound, soundId]);

  useEffect(() => {
    return () => {
      unloadSound();
      if (contextRef.current) {
        try {
          contextRef.current.close();
        } catch (err) {
          logger.error(err);
        }
        contextRef.current = null;
      }
    };
  }, [unloadSound]);

  useEffect(() => {
    const sub = AppState.addEventListener("change", async state => {
      const context = getContext();
      if (!context) return;

      try {
        if (state !== "active") {
          await context.suspend();
        } else {
          await context.resume();
        }
      } catch (err) {
        logger.error("AppState audio error", err);
      }
    });

    return () => sub.remove();
  }, [getContext]);

  return { fadeIn, fadeOut, playSound, preloadSound, stopSound };
}

versions

  • react-native-audio-api: "^0.8.2"
  • react-native: "0.79.6"

Questions:

  • are we misusing the API in some way that could trigger this?
  • is there a recommended pattern for safely stopping and unloading audio to avoid native crashes?
  • could this be a race condition between JS cleanup and the native thread?

Any guidance or suggestions would be appreciated.

Metadata

Metadata

Labels

bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions