Skip to main content

Audio

React Native has no audio API of its own; the ecosystem's reference is Expo's expo-audio, so that is Kog's shape. Import from 'expo-audio' exactly as in an Expo app. Kog resolves it to @kog/audio (you can also import @kog/audio directly). Names, argument orders, defaults and units — seconds, volume 0..1 — match expo-audio's published typings (SDK 58). The differences are listed in Divergences.

import { Pressable, Text, View } from 'react-native';
import { useAudioPlayer, useAudioPlayerStatus } from 'expo-audio';

export default function Player() {
const player = useAudioPlayer(require('./assets/song.mp3'));
const status = useAudioPlayerStatus(player);

return (
<View>
<Pressable onPress={() => (player.playing ? player.pause() : player.play())}>
<Text>{status.playing ? 'Pause' : 'Play'}</Text>
</Pressable>
<Text>
{status.currentTime.toFixed(1)} / {status.duration.toFixed(1)} s
</Text>
</View>
);
}

Audio plays on boards with a speaker in their board definition (the Waveshare AMOLED 2.06: ES8311 codec and speaker amp, ES7210 microphones), in the desktop simulator (your computer's audio device and microphone) and in the browser simulator (WebAudio, started by your first click or tap). The AMOLED 2.06 has a single speaker, so stereo sources play downmixed to mono ((left + right) / 2): nothing panned to one side is lost. On a board without audio — the CYD's firmware is built without it — your audio code still runs: players and recorders are created, each reports one error status naming the board, their properties read idle values (currentTime 0, volume 1) and settings such as volume are accepted. Only what would make or capture sound throws AUDIO_UNAVAILABLE: play(), seekTo(), prepareToRecordAsync() and record(). So one app can run on boards with and without a speaker.

Sources​

An AudioSource is expo's type: string | number | null | { uri?, assetId?, headers?, name? }.

You writeWhat playsWhere the bytes are
require('./chime.wav'), import song from './song.mp3'a bundled sound (the value is a number, the pack id)the app's asset pack in flash, decoded in place: no RAM copy of the file
'file:///sd/music/a.mp3' or { uri: 'file:///sd/music/a.mp3' }a file on the microSD cardread ahead from the card while it plays
recorder.uri after recorder.stop()a recordingthe card, or a memory recording when no card is mounted
null (or nothing)nothing yet: call player.replace(source) later

uri wins over assetId when both are given, as in expo. headers are accepted and ignored.

Formats. Kog decodes WAV (PCM 8/16/24-bit, float 32, IMA-ADPCM; mono or stereo; 8–48 kHz) and MP3 (MPEG-1/2/2.5 Layer III) on every target. The format is read from the file's bytes, never from its extension. A bundled .m4a, .aac, .ogg, .flac or .opus is a build error that names the conversion (ffmpeg -i in.m4a in.mp3), and so is a .wav whose bytes aren't WAV. A card file in an unsupported format loads with an AUDIO_FORMAT error status. See Images and assets for how bundled sounds are packed, flashed and hot-reloaded.

Network audio is not streamed yet. An http:// or https:// source throws AUDIO_SOURCE: "network audio streaming is not supported yet — download it to the SD card with fetch(), or bundle it with require()".

Players​

useAudioPlayer(source?, options?)​

Creates an AudioPlayer when the component mounts, starts loading source, and releases the player when the component unmounts.

useAudioPlayer(source?: AudioSource, options?: AudioPlayerOptions): AudioPlayer

options.updateInterval (ms, default 500) is how often status updates arrive while playing. downloadFirst has nothing to do (every source is local), and keepAudioSessionActive, preferredForwardBufferDuration, allowsExternalPlayback and crossOrigin are accepted and ignored.

When the source is a prop or state that changes, the player follows it: the compiler passes the argument along live, and the hook calls player.replace(next) on the same player. (expo creates a new player instead, so a Kog player keeps its volume, loop and playbackRate across the change.)

createAudioPlayer(source?, options?)​

The same player, not tied to a component. Call player.remove() when you are done with it.

AudioPlayer​

MemberTypeNotes
idstring
playing, pausedbooleanread from the native player when you read them
isLoaded, isBufferingboolean
currentTime, durationnumber (s)duration is 0 until the header is parsed
volumenumber0..1; values outside are clamped
muted, loopbooleansettable
playbackRatenumbersettable, 0.25..4; outside throws a RangeError. The pitch changes with the rate.
shouldCorrectPitchbooleanalways false; setting true throws AUDIO_UNAVAILABLE
isAudioSamplingSupportedbooleantrue on any target with an audio output
currentStatusAudioStatusthe latest status snapshot
play()voidthrows AUDIO_UNAVAILABLE (no speaker), AUDIO_BUSY (8 players already playing)
pause()void
replace(source)voidloads a new source; a playing player keeps playing (the new source starts once loaded). Load failures arrive as an error status, not a throw.
seekTo(seconds, toleranceBefore?, toleranceAfter?)Promise<void>resolves once positioned; the iOS tolerances are accepted
setPlaybackRate(rate, pitchCorrectionQuality?)voidpitchCorrectionQuality is iOS-only in expo; ignored
addListener('playbackStatusUpdate', cb)EventSubscriptioncb(status) on every state change and every updateInterval while playing; .remove() to stop
addListener('audioSampleUpdate', cb)EventSubscriptionthe sample tap (see useAudioSampleListener)
removeListener, removeAllListeners, listenerCountexpo's SharedObject event API
setActiveForLockScreen, updateLockScreenMetadata, clearLockScreenControlsvoidaccepted, no effect (no lock screen)
remove() (alias release())voidfrees the native player; later calls throw

Up to 8 players play at once; they are mixed in software. A ninth play() throws AUDIO_BUSY rather than silently stopping another one.

useAudioPlayerStatus(player)​

Returns the player's current AudioStatus, and it stays current: the compiler keeps const status = useAudioPlayerStatus(player) live, so status.playing, status.currentTime, and anything you derive from them (const pct = status.currentTime / status.duration) update in place as playback runs. No re-render happens; only the text or style that reads the status changes.

type AudioStatus = {
id: string;
currentTime: number; // s
duration: number; // s, 0 until known
playing: boolean;
isLoaded: boolean;
isBuffering: boolean;
didJustFinish: boolean; // true on the one update where playback reached the end
loop: boolean;
mute: boolean;
playbackRate: number;
playbackState: string; // 'idle' | 'loading' | 'buffering' | 'ready' | 'ended' | 'failed'
timeControlStatus: string; // 'playing' | 'paused' | 'waitingToPlayAtSpecifiedRate'
reasonForWaitingToPlay: string;
shouldCorrectPitch: boolean; // always false
isLive: boolean; // always false
currentOffsetFromLive: number | null;
error: string | null; // why loading or playback failed, naming the fix
};

useAudioSampleListener(player, listener)​

Turns on the player's sample tap while the component is mounted and calls listener with { channels: [{ frames }], timestamp } up to 30 times a second while it plays. Kog delivers one channel: that player's samples after its volume, mixed to mono, frames in -1..1 (the most recent up-to-1024 frames since the last call; older ones are dropped). Use it for level meters and waveforms.

Recording​

import { Pressable, Text } from 'react-native';
import { useAudioRecorder, useAudioRecorderState, RecordingPresets } from 'expo-audio';

export default function Memo() {
const recorder = useAudioRecorder({ ...RecordingPresets.HIGH_QUALITY, sampleRate: 16000, numberOfChannels: 1 });
const state = useAudioRecorderState(recorder);

const toggle = async () => {
if (state.isRecording) {
await recorder.stop();
console.log('saved', recorder.uri);
} else {
await recorder.prepareToRecordAsync();
recorder.record();
}
};

return (
<Pressable onPress={toggle}>
<Text>{state.isRecording ? `Stop ${Math.round(state.durationMillis / 1000)} s` : 'Record'}</Text>
</Pressable>
);
}

Kog records 16-bit PCM into a WAV file, whatever the options' extension says. sampleRate (8000–48000) and numberOfChannels (1 or 2) are honoured; bitRate and the android/ios/web encoder fields are ignored. The file goes to the card root when a card is mounted (file:///sd/rec-<n>.wav, or file:///sd/<fileName>.wav), otherwise to a memory recording (memory://recording/<n>) that is capped at 60 s; reaching the cap stops the recorder with an error status that says so. Either uri is a valid player source.

useAudioRecorder(options, statusListener?)​

Creates an AudioRecorder on mount and releases it on unmount. statusListener receives RecordingStatus ({ id, isFinished, hasError, error, url }) when a recording finishes or fails.

useAudioRecorderState(recorder, interval = 500)​

Returns the recorder's RecorderState (canRecord, isRecording, durationMillis, fileSize, metering, url, mediaServicesDidReset), refreshed every interval ms while recording and on every state change. It is live, like useAudioPlayerStatus. metering (dBFS, RMS over ~100 ms) is present when isMeteringEnabled is set.

AudioRecorder​

MemberNotes
prepareToRecordAsync(options?)merges options into the constructor's and prepares the file
record(options?)starts or resumes. { forDuration } stops after that many recorded seconds (pauses don't count); { atTime } delays the start. An unprepared recorder is prepared first. Throws AUDIO_UNAVAILABLE on a board without a microphone and AUDIO_BUSY while another recorder records.
pause()
stop()Promise<void>: resolves once the WAV is finalized and uri points at it
recordForDuration(s), startRecordingAtTime(s)expo's deprecated forms of record({ forDuration }) / record({ atTime })
getStatus()the current RecorderState
uri, isRecording, currentTime (s), id
getAvailableInputs(), getCurrentInput(), setInput(uid)one input, the board's microphone: { name: 'Microphone', type: 'Built-in Microphone', uid: 'default' }
addListener('recordingStatusUpdate', cb), release()

RecordingPresets.HIGH_QUALITY / LOW_QUALITY, AudioQuality and IOSOutputFormat are expo's, unchanged, so ported code compiles.

Playlists​

useAudioPlaylist({ sources, loop, updateInterval }), createAudioPlaylist(options) and useAudioPlaylistStatus(playlist) (live) work as in expo: play, pause, next, previous, skipTo(i), seekTo, add, insert, remove(i), clear, destroy, the currentIndex, trackCount, sources, volume, muted, playbackRate and loop ('none' | 'single' | 'all') properties, and the playlistStatusUpdate and trackChanged events. A playlist plays its tracks through one player; moving to the next track reloads it, so tracks are not gapless (a few milliseconds between them). loop: 'single' repeats seamlessly.

Module functions​

FunctionOn Kog
setAudioModeAsync(mode)Accepted, no effect: one app owns the device's speaker and microphone, so there is no session to configure.
setIsAudioActiveAsync(active)false pauses every player and recorder, and play()/record() then throw until setIsAudioActiveAsync(true).
requestRecordingPermissionsAsync(), getRecordingPermissionsAsync()Always { status: 'granted', granted: true, canAskAgain: true, expires: 'never' }: there is no permission system.
requestNotificationPermissionsAsync()Rejects, as expo does off Android.
preload(source), clearPreloadedSource, clearAllPreloadedSources, getPreloadedSourcesEvery source is local and opens immediately, so preload only validates (an http URL rejects) and records the source.
useAudioStream, AudioStreamThrow: real-time PCM capture isn't supported yet. Record a WAV and play its uri.
AudioModuleexpo's module object: the functions above plus the AudioPlayer, AudioRecorder and AudioPlaylist classes.

Errors​

Failures throw (or reject with) an AudioError whose stable .code you can branch on, and whose message names the fix:

.codeWhen
AUDIO_UNAVAILABLEplay()/seekTo() on a board with no audio output (and, on one whose firmware has no audio, the one error status each player and recorder reports), prepareToRecordAsync()/record() on one with no microphone, play()/record() after setIsAudioActiveAsync(false), shouldCorrectPitch = true, useAudioStream.
AUDIO_SOURCEan http(s) source, an invalid asset id, an image require() passed as a sound, a missing card file (as an error status), a removed player.
AUDIO_FORMATa card file the decoders can't play (as an error status).
AUDIO_BUSYa ninth concurrent play(), a second recorder recording.

Load errors arrive asynchronously, as a status with error set (and a playbackStatusUpdate); the message is also printed to the console, so a failure is visible even when nothing reads the status.

Testing​

@kog/audio/testkit installs a deterministic mock of the native audio core for unit tests:

import { createMockHost } from '@kog/runtime/testkit';
import { installMockAudio } from '@kog/audio/testkit';

createMockHost(); // first: it replaces globalThis.__kog
const audio = installMockAudio(); // { caps?, sdCard?, memoryCapMs?, sampling? }
audio.addAsset(0, { duration: 2 }); // require('./x.wav') === 0 in the bundle
audio.addFile('file:///sd/a.mp3', { duration: 30 });
// … mount, press play …
audio.advance(1000); // plays every voice 1 s forward, emits status ticks,
// finishes/loops tracks and advances host timers in step

Divergences from expo-audio​

expo-audioKogWhy
AAC/.m4a, Opus, Ogg, FLACWAV and MP3 only. A bundled file in another format is a build error naming the ffmpeg conversion; a card file reports AUDIO_FORMAT.The decoders run on a microcontroller.
Recording presets write AAC .m4aWAV, PCM s16. The preset's sampleRate and numberOfChannels are honoured, bitRate is ignored.There is no AAC encoder on the device.
http(s) sources streamThrows AUDIO_SOURCE naming the alternatives.Not in v1.
playbackRate with pitch correctionThe rate changes the pitch; shouldCorrectPitch is always false and setting it true throws. Rates are 0.25..4.No time-stretch DSP.
Lock-screen controls, background and interruption modes, setAudioModeAsync fieldsAccepted, no effect.One app owns the device; there is no OS.
PermissionsAlways granted.There is no permission system.
useAudioSampleListener gives one array per channelOne channel, the output mixed to mono, at up to 30 Hz.
useAudioPlayer re-creates the player when source changesIt calls replace() on the same player, which keeps its volume, loop and rate.A Kog component body runs once.
playbackState strings are per platform'idle' | 'loading' | 'buffering' | 'ready' | 'ended' | 'failed'There is one platform.
Playlists are gaplessNot gapless: a few ms between tracks (loop: 'single' is seamless).One player reloads per track.
useAudioStream captures PCM in real timeThrows.Not in v1; record a WAV instead.
Up to the OS's limit of concurrent players8 playing at once; a ninth play() throws AUDIO_BUSY.The mixer has 8 voices.
A memory recording is bounded by device storageWithout an SD card, a recording is capped at 60 s and stops with an error status.It lives in PSRAM.