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 write | What plays | Where 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 card | read ahead from the card while it plays |
recorder.uri after recorder.stop() | a recording | the 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
| Member | Type | Notes |
|---|---|---|
id | string | |
playing, paused | boolean | read from the native player when you read them |
isLoaded, isBuffering | boolean | |
currentTime, duration | number (s) | duration is 0 until the header is parsed |
volume | number | 0..1; values outside are clamped |
muted, loop | boolean | settable |
playbackRate | number | settable, 0.25..4; outside throws a RangeError. The pitch changes with the rate. |
shouldCorrectPitch | boolean | always false; setting true throws AUDIO_UNAVAILABLE |
isAudioSamplingSupported | boolean | true on any target with an audio output |
currentStatus | AudioStatus | the latest status snapshot |
play() | void | throws AUDIO_UNAVAILABLE (no speaker), AUDIO_BUSY (8 players already playing) |
pause() | void | |
replace(source) | void | loads 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?) | void | pitchCorrectionQuality is iOS-only in expo; ignored |
addListener('playbackStatusUpdate', cb) | EventSubscription | cb(status) on every state change and every updateInterval while playing; .remove() to stop |
addListener('audioSampleUpdate', cb) | EventSubscription | the sample tap (see useAudioSampleListener) |
removeListener, removeAllListeners, listenerCount | expo's SharedObject event API | |
setActiveForLockScreen, updateLockScreenMetadata, clearLockScreenControls | void | accepted, no effect (no lock screen) |
remove() (alias release()) | void | frees 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
| Member | Notes |
|---|---|
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
| Function | On 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, getPreloadedSources | Every source is local and opens immediately, so preload only validates (an http URL rejects) and records the source. |
useAudioStream, AudioStream | Throw: real-time PCM capture isn't supported yet. Record a WAV and play its uri. |
AudioModule | expo'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:
.code | When |
|---|---|
AUDIO_UNAVAILABLE | play()/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_SOURCE | an 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_FORMAT | a card file the decoders can't play (as an error status). |
AUDIO_BUSY | a 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-audio | Kog | Why |
|---|---|---|
AAC/.m4a, Opus, Ogg, FLAC | WAV 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 .m4a | WAV, PCM s16. The preset's sampleRate and numberOfChannels are honoured, bitRate is ignored. | There is no AAC encoder on the device. |
http(s) sources stream | Throws AUDIO_SOURCE naming the alternatives. | Not in v1. |
playbackRate with pitch correction | The 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 fields | Accepted, no effect. | One app owns the device; there is no OS. |
| Permissions | Always granted. | There is no permission system. |
useAudioSampleListener gives one array per channel | One channel, the output mixed to mono, at up to 30 Hz. | |
useAudioPlayer re-creates the player when source changes | It 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 gapless | Not gapless: a few ms between tracks (loop: 'single' is seamless). | One player reloads per track. |
useAudioStream captures PCM in real time | Throws. | Not in v1; record a WAV instead. |
| Up to the OS's limit of concurrent players | 8 playing at once; a ninth play() throws AUDIO_BUSY. | The mixer has 8 voices. |
| A memory recording is bounded by device storage | Without an SD card, a recording is capped at 60 s and stops with an error status. | It lives in PSRAM. |