- 
          
 - 
                Notifications
    
You must be signed in to change notification settings  - Fork 33
 
Labels
bugSomething isn't workingSomething isn't working
Description
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
Assignees
Labels
bugSomething isn't workingSomething isn't working