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 |
Installation
Section titled “Installation”npm install @symbiote-native/audioScaffolding 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 opt-in
Section titled “Background recording is opt-in”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):
npx @symbiote-native/cli grant audioThat 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> );}<script setup lang="ts">import { setAudioModeAsync, useAudioPlayer, useAudioPlayerStatus,} from '@symbiote-native/audio/vue';
void setAudioModeAsync({ playsInSilentMode: true, shouldPlayInBackground: false });
const player = useAudioPlayer('https://example.com/track.mp3');const status = useAudioPlayerStatus(() => player.value);
function onPress() { if (status.value.playing) player.value.pause(); else player.value.play();}</script>
<template> <view> <text>{{ status.currentTime.toFixed(1) }} / {{ status.duration.toFixed(1) }}</text> <button :title="status.playing ? 'Pause' : 'Play'" @press="onPress" /> </view></template>The source and options accept a plain value, a Ref or a getter, and the hook returns a
ComputedRef of the player.
import { Component } from '@angular/core';import { SYMBIOTE_ELEMENTS } from '@symbiote-native/angular';import { injectAudioPlayer, injectAudioPlayerStatus,} from '@symbiote-native/audio/angular';
@Component({ standalone: true, imports: [SYMBIOTE_ELEMENTS], template: ` <view> <text>{{ status().currentTime.toFixed(1) }} / {{ status().duration.toFixed(1) }}</text> <button [title]="status().playing ? 'Pause' : 'Play'" (press)="onPress()" /> </view> `,})export class Track { readonly player = injectAudioPlayer(() => 'https://example.com/track.mp3'); readonly status = injectAudioPlayerStatus(() => this.player());
onPress(): void { if (this.status().playing) this.player().pause(); else this.player().play(); }}Call the inject* functions in a field initializer (an injection context). They take
functions that read signals, and return signals.
<script lang="ts"> import { setAudioModeAsync, useAudioPlayer, useAudioPlayerStatus, } from '@symbiote-native/audio/svelte';
void setAudioModeAsync({ playsInSilentMode: true, shouldPlayInBackground: false });
const player = useAudioPlayer(() => 'https://example.com/track.mp3'); const status = useAudioPlayerStatus(() => player.current); const current = $derived(status.current);</script>
<view> <text>{current.currentTime.toFixed(1)} / {current.duration.toFixed(1)}</text> <button title={current.playing ? 'Pause' : 'Play'} onPress={() => (current.playing ? player.current.pause() : player.current.play())} /></view>Svelte hooks take getters and return { current } objects.
import { setAudioModeAsync, useAudioPlayer, useAudioPlayerStatus,} from '@symbiote-native/audio/solid';
void setAudioModeAsync({ playsInSilentMode: true, shouldPlayInBackground: false });
export 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> );}Solid hooks take accessors and return accessors: call them (player(), status()).
Outside a component
Section titled “Outside a component”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();Record the microphone
Section titled “Record the microphone”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.
Playlists and PCM streams
Section titled “Playlists and PCM streams”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();Classes and factories
Section titled “Classes and factories”| 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() |
AudioPlayer essentials
Section titled “AudioPlayer essentials”| 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 |
Module functions
Section titled “Module functions”| 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.
Lifecycle hooks
Section titled “Lifecycle hooks”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.
createAudioPlayerand the other factories leave cleanup to you; the hooks do it automatically. A forgottenremove()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 onceshouldPlayInBackground: trueis set. - Audio stops when headphones disconnect. That is upstream behavior on both platforms.
interruptionModeAndroidis not ported. Upstream deprecated it in favor of the cross-platforminterruptionMode.- 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.
Common questions
Section titled “Common questions”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.
How the wrapper works
Section titled “How the wrapper works”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).