-
-
Notifications
You must be signed in to change notification settings - Fork 38
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