@matiks/rn-fastaudio
v1.1.0
Published
Low-latency audio for React Native, built on Nitro modules — Oboe on Android, AVAudioEngine on iOS. Built for game-style SFX.
Readme
@matiks/rn-fastaudio
Low-latency audio for React Native, built on Nitro modules (direct JSI —
no bridge, no serialization). Android runs on Oboe with a single
mixer thread; iOS on AVAudioEngine with one source node per track.
Built for game-style SFX: many short sounds, fired fast, that must not block the JS thread.
For architecture, threading internals and the resampler/reverb design, see Docs.md.
Contents
- Install · Quick start · Concepts
- API: Factories · AudioPlayer · AudioDecodePool · AudioScope · AudioBus · AudioGroup · AudioSession · AudioAnalyzer · AudioRecorder
- Helpers · Hooks · Types · Platform notes
Install
yarn add @matiks/rn-fastaudio react-native-nitro-modules
cd ios && pod installAndroid needs no extra setup for playback. Recording requires the host app to declare
RECORD_AUDIO itself — see Recording.
Quick start
import { createAudioPlayer } from '@matiks/rn-fastaudio';
const player = createAudioPlayer();
await player.load({ uri: 'file:///path/to/sound.mp3', volume: 0.8 });
player.play();
// …later
player.stop();
player.release(); // always release: this frees the native trackFiring the same short sound repeatedly is what the decode pool is for:
import { globalDecodePool } from '@matiks/rn-fastaudio';
await globalDecodePool.add('pop', popUri);
globalDecodePool.play('pop'); // decoded once, replayable instantlyConcepts
Release is mandatory. Every player owns a native track and a ring buffer. Dropping the JS
reference does not free them. Call release(), or use AudioScope / AudioGroup / the hooks,
which release for you.
One player is one voice. A player plays one sound at a time. AudioDecodePool.play(id) is
seekTo(0) + play(), so firing the same id twice restarts it rather than layering. To overlap
a sound with itself, create multiple players for it — the mixer handles many concurrent tracks
(verified at 10 simultaneous voices).
Decode once, replay free. Decoded PCM is cached per URI and reference-counted, so several players on the same file share one buffer. A fully-decoded track replays without touching the codec.
Buffer sizing. bufferSizeMs (default 2000) sizes the per-player ring buffer at
ms/1000 × 48000 × 2 × 4 bytes ≈ 768 KB at the default. For short SFX, lower it.
Factories
| Function | Returns |
| --- | --- |
| createAudioPlayer() | AudioPlayer |
| createAudioRecorder() | AudioRecorder |
| createAudioSession() | AudioSession |
| createAudioGroup() | AudioGroup |
| createAudioAnalyzer() | AudioAnalyzer |
| createTrackedPlayer() | AudioPlayer, registered for getActiveTrackCount() |
AudioPlayer
Lifecycle
| Method | Notes |
| --- | --- |
| load(options: AudioPlayerOptions): Promise<void> | Resolves once the source is prepared (codec configured), not once fully decoded. |
| play(): void | Resumes from the current position. |
| pause(): void | Keeps position. |
| stop(): void | Stops and rewinds to 0. |
| seekTo(positionMs: number): void | |
| release(): void | Frees the native track. Not optional. |
Properties (read-only, synchronous)
state: AudioState · positionMs: number · durationMs: number · volume: number
Synchronous reads make 60 fps progress bars possible without round-trips.
Volume, rate, loop
| Method | Range |
| --- | --- |
| setVolume(volume: number) | 0–1 |
| setRate(rate: number) | playback speed |
| setLoop(loop: boolean) | |
| fadeVolume(toVolume: number, durationMs: number) | fades on the audio thread |
| setBoost(multiplier: number) | 0.25–4, multiplied with volume. Above 1 amplifies; output is hard-limited, so extreme boosts clip audibly. |
| normalize(targetPeak: number): Promise<number> | Peak-normalizes so the loudest sample hits targetPeak (0–1). Resolves with the gain applied. Requires a fully decoded (cached) track — rejects for streaming or not-yet-decoded sources. |
Spatial / effects
setPan(value: number)—-1full left,0center,1full rightsetReverb(wetDry: number)—0dry …1full wet
Segments & analysis
playFromMs(startMs: number, endMs: number)— plays a slice then stops;endMs = -1plays to the endgetWaveform(samples: number): Promise<number[]>— amplitude envelope of the loaded track, normalized0..1
Events
player.onStatusChange = ({ state, positionMs, durationMs, error }) => { … };
player.onStatusChange = null; // detachAudioDecodePool
Keyed cache of decoded players — the right tool for UI/game SFX. globalDecodePool is a shared
instance; new AudioDecodePool() gives you a scoped one.
| Method | Notes |
| --- | --- |
| add(id, uri, opts?): Promise<AudioPlayer> | Decode and store under id. |
| decodeAll(entries, opts?) | Bulk add. |
| play(id): void | seekTo(0) + play(). Self-heals: if the entry was cleared, re-decodes and fires once ready. |
| seek(id, ms): void | |
| get(id) / getPlayer(id) | AudioPlayer \| null |
| isDecoded(id): boolean | True once load() resolved (prepared) — not a guarantee that PCM is in memory. |
| waitFor(id): Promise<AudioPlayer \| null> | Await an in-flight decode. |
| normalizeAll(targetPeak?): Promise<Record<string, number>> | Peak-normalize every entry; returns per-id gains. |
| clearById(id): void | Stop, release, drop — keeps the uri so a later play(id) can revive it. |
| forget(id): void | clearById and drop the uri; play(id) afterwards is a no-op. |
| clearAll(): void | |
| ids: string[] · size: number | |
Releasing a player mid-playback cuts the sound off. Check
player.state !== 'playing'before clearing an entry on screen exit.
AudioScope
Page-scoped ownership: every player it creates dies with the scope.
const scope = new AudioScope('checkout');
const player = scope.createPlayer();
// …
scope.destroy(); // stops + releases everything it handed outcreatePlayer() · freePlayer(player) · stopAll() · pauseAll() · resumeAll() · destroy()
AudioBus
Group several players under shared JS-side controls.
add(player) · remove(player) · setVolume(v) · setPan(p) · setReverb(wetDry) ·
fadeVolume(to, durationMs) · play() · pause() · stop() · release()
AudioGroup
Same idea as AudioBus, but the bulk loops run natively — one JSI call per operation instead of
one per player. Prefer this when the group is large.
addPlayer(player) · removePlayer(player) · stopAll() · pauseAll() · resumeAll() ·
releaseAll() · playerCount
AudioSession
const session = createAudioSession();
await session.requestFocus('playback'); // 'playback' | 'ambient' | 'record' | 'playAndRecord'
session.abandonFocus();
session.nativeSampleRate; // device output rate
session.nativeBufferSize; // device frames-per-burstAudioAnalyzer
Metadata and waveforms without loading a player.
const analyzer = createAudioAnalyzer();
const meta = await analyzer.getMetadata(uri); // AudioMetadata
const wave = await analyzer.getWaveform(uri, 64); // number[]AudioRecorder
Requires
RECORD_AUDIO. This library does not declare the permission — the host app must add it to its own manifest and request it at runtime. Without it,prepare()fails to open the input stream and a subsequentstart()dereferences a null stream and crashes the process. Check permission, thenprepare(), and only callstart()ifprepare()resolved.
const rec = createAudioRecorder();
await rec.prepare({ outputPath, sampleRate: 44100, channels: 1, bitRate: 128000 });
rec.start();
rec.pause();
rec.resume();
const path = await rec.stop(); // '' if the file could not be written
rec.release();isRecording: boolean · durationMs: number · metering: number (dBFS) ·
onMeteringUpdate: ((dbfs: number) => void) | null
Helpers
Timing (each returns a cancel function):
playFor(player, durationMs)— play, auto-stop afterdurationMsschedulePlay(player, delayMs)— play after a delayschedulePlayFor(player, delayMs, durationMs)— both
Playback utils:
playFromMs(player, startMs, endMs)— JS-side segment playbackcrossfadeTo(from, to, durationMs)— fade one player out while the other comes inpreload(player, options): Promise<void>— load without playing
Tracked players (leak accounting):
createTrackedPlayer()/releaseTrackedPlayer(player)/untrackPlayer(player)getActiveTrackCount(): number— counts only players made withcreateTrackedPlayer(); it stays at 0 forcreateAudioPlayer(), so a leak check built on it must use the tracked constructor or it silently measures nothing.
Hooks
| Hook | Returns |
| --- | --- |
| useAudioPlayer(options?) | play, pause, stop, playForMs, schedulePlayMs, onStatusChange, setPan, setReverb, playFromMs, getWaveform, playerRef |
| useAudioProgress(playerRef, intervalMs?) | { positionMs, durationMs } |
| useAudioRecorder() | prepare, start, pause, resume, stop, onMeteringUpdate |
| useAudioSession(mode?) | manages focus for the component's lifetime (ref-counted across mounts); 'ambient' mixes with other apps and obeys the iOS silent switch |
| useAudioGroup() | groupRef, playerCount, addPlayer, removePlayer, stopAll, pauseAll, resumeAll, releaseAll |
| useAudioBus() | busRef, add, remove, setVolume, setPan, setReverb, fadeVolume, play, pause, stop |
| useAudioAnalyzer() | getMetadata, getWaveform |
| useDecodePool(entries, opts?) | play, seek, getPlayer, isReady, pool — decodes on mount, clears on unmount |
| usePageAudio(pageName) | scope, playerCount, createPlayer, loadPlayer, freePlayer, stopAll, pauseAll, resumeAll, destroyScope |
All of them release their native resources on unmount.
Types
type AudioState = 'idle' | 'loading' | 'playing' | 'paused' | 'stopped' | 'error';
type AudioFormat = 'mp3' | 'aac' | 'flac' | 'wav' | 'ogg';
interface AudioPlayerOptions {
uri: string;
volume?: number; // 0–1
loop?: boolean;
rate?: number;
startPositionMs?: number;
bufferSizeMs?: number; // default 2000
streaming?: boolean; // stream a remote uri instead of downloading first
}
interface AudioRecorderOptions {
outputPath: string;
format?: AudioFormat;
sampleRate?: number;
channels?: 1 | 2;
bitRate?: number;
}
interface AudioStatusEvent { state: AudioState; positionMs: number; durationMs: number; error?: string }
interface AudioMetadata { durationMs: number; sampleRate: number; channels: number;
bitrate?: number; title?: string; artist?: string; album?: string }Platform notes
Remote sources. http(s) URIs are downloaded to a native disk cache and decoded from there, so
only the first fetch touches the network. streaming: true skips the cache and streams instead.
R8 / ProGuard (Android). FastAudioSession.instance is read from C++ by field name, so R8
must not rename it. The library ships consumer-rules.pro covering this; if you replace the
consumer rules, keep that keep-rule or audio focus silently fails in release builds with
NoSuchFieldError.
Sample rates. Sources are resampled to the device output rate. A mismatch between the decoder's
rate and the mixer stream is logged at FastAudio_Track — pitch problems show up there first.
Logging. Native logs use the tags FastAudio_Decoder, FastAudio_Track, FastAudio_Mixer,
FastAudio_Cache and FastAudio_Waveform.
