Skip to content

Audio

Play a track, record the microphone, queue a playlist or read raw PCM samples, and keep playing when the app goes to the background. @symbiote-native/audio wraps expo-audio so every SymbioteNative adapter can reach it, not just React.

AudioPlayer, AudioRecorder, AudioPlaylist and AudioStream are JSI-backed native objects with real state and methods, not one-shot functions. Every upstream lifecycle hook is ported to every adapter (useAudioPlayer in React, Vue, Svelte and Solid; injectAudioPlayer in Angular), so the player is created, replaced and released with the component. The same classes and factory functions are also available for use outside a component.

OS platform Support
iOS live
Android live
Framework adapter Support
React live
Vue live
Angular live
Svelte live
Solid live
Terminal window
npm install @symbiote-native/audio

Scaffolding or extending a SymbioteNative app? npx @symbiote-native/cli new --audio (or add --audio in an existing app) installs and wires this for you - see @symbiote-native/cli.

expo-audio and expo-modules-core come along as regular dependencies, pinned to exact versions. Never install either yourself, and never add the expo meta-package to your project (it bundles its own Metro/Babel pipeline, which conflicts with this project’s own).

What the package adds to your native projects on install (nothing overwrites a value you set):

Platform Added Why
iOS NSMicrophoneUsageDescription The microphone prompt when recording. Reword the default text
iOS UIBackgroundModes: audio Without it iOS suspends playback when the app backgrounds
Android RECORD_AUDIO, MODIFY_AUDIO_SETTINGS Ship in expo-audio’s own manifest and merge automatically
Android FOREGROUND_SERVICE, FOREGROUND_SERVICE_MEDIA_PLAYBACK, AudioControlsService Background playback, matching upstream’s default

Background recording is a deliberate app choice, as upstream defaults it to off: it adds a microphone foreground service and a persistent notification, and costs battery. The new --audio and add --audio commands ask whether to grant it. If you said no, or ran non-interactively, run it any time (it is safe to repeat):

Terminal window
npx @symbiote-native/cli grant audio

That adds FOREGROUND_SERVICE_MICROPHONE, POST_NOTIFICATIONS and the AudioRecordingService to your AndroidManifest.xml. You still pass allowsBackgroundRecording: true in setAudioModeAsync.

Set the audio mode once, then create a player with the adapter’s binding. It recreates the player when the source changes and releases the old one. useAudioPlayerStatus subscribes to playback updates and returns the current status.

import { setAudioModeAsync, useAudioPlayer, useAudioPlayerStatus } from '@symbiote-native/audio/react';
void setAudioModeAsync({ playsInSilentMode: true, shouldPlayInBackground: false });
export default function Track() {
const player = useAudioPlayer('https://example.com/track.mp3');
const status = useAudioPlayerStatus(player);
return (
<view>
<text>{status.currentTime.toFixed(1)} / {status.duration.toFixed(1)}</text>
<button
title={status.playing ? 'Pause' : 'Play'}
onPress={() => (status.playing ? player.pause() : player.play())}
/>
</view>
);
}

Use the factories when the player outlives a component. You own the cleanup: call remove() when you are done, or the native player leaks.

import {
createAudioPlayer,
PLAYBACK_STATUS_UPDATE,
setAudioModeAsync,
} from '@symbiote-native/audio';
await setAudioModeAsync({ playsInSilentMode: true, shouldPlayInBackground: false });
const player = createAudioPlayer('https://example.com/track.mp3');
player.play();
const subscription = player.addListener(PLAYBACK_STATUS_UPDATE, status => {
console.log(status.currentTime, status.duration, status.playing);
});
// later:
subscription.remove();
player.remove();
import {
AudioRecorder,
RECORDING_STATUS_UPDATE,
RecordingPresets,
requestRecordingPermissionsAsync,
} from '@symbiote-native/audio';
const { granted } = await requestRecordingPermissionsAsync();
if (!granted) throw new Error('Microphone permission denied');
const recorder = new AudioRecorder(RecordingPresets.HIGH_QUALITY);
await recorder.prepareToRecordAsync();
recorder.record();
recorder.addListener(RECORDING_STATUS_UPDATE, status => console.log(status));
// later:
await recorder.stop();
console.log(recorder.uri);

Inside a component, useAudioRecorder(options, statusListener?) creates and releases the recorder for you, and useAudioRecorderState(recorder, interval?) polls its state.

const playlist = createAudioPlaylist({ sources: ['a.mp3', 'b.mp3'], loop: 'all' });
const stream = createAudioStream({ sampleRate: 16000, channels: 1 });
stream.addListener(AUDIO_STREAM_BUFFER, buffer => console.log(buffer));
await stream.start();
Signature Description
createAudioPlayer(source?, options?): AudioPlayer Creates a player. Call remove() when done
new AudioRecorder(options) / AudioRecorder Records the microphone. prepareToRecordAsync(), record(), pause(), stop()
createAudioPlaylist(options?): AudioPlaylist Creates a queue with next, previous, skipTo, add, insert, remove, clear, loop
createAudioStream(options?): AudioStream Creates a real-time PCM stream. start() then listen for buffers; stop()
Member Description
play(), pause() Start and pause playback
seekTo(seconds, before?, after?) Jump to a position, with optional tolerance in milliseconds
replace(source) Swap the audio source
volume, muted, loop, playbackRate Read and write playback settings
setPlaybackRate(rate, quality?) Set speed, optionally with a pitch-correction quality
currentTime, duration, playing, isLoaded, isBuffering Current state
setActiveForLockScreen(active, metadata?, options?) Show lock-screen and notification controls
updateLockScreenMetadata(metadata) Update the title, artist or artwork shown there
clearLockScreenControls() Remove the lock-screen controls
remove() Release the native player
Signature Description
setAudioModeAsync(mode) Configure silent mode, background playback, interruption and mixing behavior
setIsAudioActiveAsync(active) Activate or deactivate the audio session
requestRecordingPermissionsAsync() Prompt for the microphone permission
getRecordingPermissionsAsync() Read the microphone permission without prompting
requestNotificationPermissionsAsync() Android only. Prompt for notifications; throws on iOS
preload(source, options?) Buffer a source before it is played
clearPreloadedSource(source) Drop one preloaded source
clearAllPreloadedSources() Drop every preloaded source
getPreloadedSources() List the preloaded source URIs
RecordingPresets HIGH_QUALITY and LOW_QUALITY option sets for AudioRecorder

The event names for addListener are exported as constants: PLAYBACK_STATUS_UPDATE, AUDIO_SAMPLE_UPDATE, RECORDING_STATUS_UPDATE, PLAYLIST_STATUS_UPDATE, TRACK_CHANGED, AUDIO_STREAM_BUFFER and AUDIO_STREAM_STATUS.

IAudioSource accepts a URI string, a require('./song.mp3') module id, an @symbiote-native/asset Asset, or an object with uri or assetId plus optional headers and name.

Every hook is available on all five adapters (Angular names each injectX).

Hook Description
useAudioPlayer(source?, options?) Creates a player, recreates it when source or options change, releases it
useAudioPlayerStatus(player) Subscribes to playback updates and returns the current status
useAudioSampleListener(player, listener) Enables sampling and calls listener with each audio sample
useAudioPlaylist(options?) Same lifecycle as useAudioPlayer, for a playlist
useAudioPlaylistStatus(playlist) Subscribes to playlist updates and returns the current status
useAudioRecorder(options, statusListener?) Creates and releases a recorder, with an optional status subscription
useAudioRecorderState(recorder, interval?) Polls the recorder state and updates only on a meaningful change
useAudioStream(options) Creates a stream keyed on sample rate, channels and encoding, and subscribes
  • Silent mode on iOS. Call setAudioModeAsync({ playsInSilentMode: true }) or playback is muted by the ring/silent switch.
  • Release what you create by hand. createAudioPlayer and the other factories leave cleanup to you; the hooks do it automatically. A forgotten remove() leaks the native player.
  • Android background playback needs lock-screen controls. Without setActiveForLockScreen, Android stops background audio after about three minutes (an OS limit). iOS continues once shouldPlayInBackground: true is set.
  • Audio stops when headphones disconnect. That is upstream behavior on both platforms.
  • interruptionModeAndroid is not ported. Upstream deprecated it in favor of the cross-platform interruptionMode.
  • Web is not ported. This project targets iOS and Android only.
  • Playback and recording can only be verified on a device. The headless tests fake the native classes, so they prove the JS shims and the hook lifecycle.

Playback is silent on iPhone. Call setAudioModeAsync({ playsInSilentMode: true }), or the ring/silent switch mutes it.

Audio stops when the app goes to the background. Set shouldPlayInBackground: true in setAudioModeAsync. The package already adds the iOS audio background mode and the Android playback service. On Android, also call setActiveForLockScreen, or the OS stops background audio after about three minutes.

Recording fails with a permission error. Ask first with requestRecordingPermissionsAsync() and check granted. Background recording is a separate opt-in (npx @symbiote-native/cli grant audio).

How do I change the track without recreating the player? Call player.replace(source). With useAudioPlayer, a changed source recreates the player and releases the old one for you.

My app leaks audio players. A player made with createAudioPlayer must be released with remove(). The hooks do it automatically.

How do I show progress? Use useAudioPlayerStatus(player) (or injectAudioPlayerStatus) for currentTime, duration and playing.

Audio stopped when I unplugged headphones. That is upstream behavior on both platforms.

Sources: Expo docs: Audio (expo-audio), expo/expo#24484 iOS audio not continuing playback in the background, expo/expo#43086 no way to route audio to the speaker on iOS.

expo-audio’s JS is hand-ported into this package’s core/: thin subclasses of the native SharedObject classes plus the factories, module functions and presets, resolving the native module through expo-modules-core rather than the expo meta-package. The recreate-and-release and event-subscription logic lives once in core/ (for example audio-player-controller.ts); each adapter supplies only its own lifecycle primitive. The native code is never vendored: expo-modules-autolinking resolves it from node_modules (see the native setup guide).