Native audio

OpenTUI exposes a native miniaudio-backed Audio engine from @opentui/core. It can decode audio bytes or files, play sounds through named groups, choose playback devices, mix into PCM buffers, and expose recent mixed frames for visualization.

Basic usage

import { Audio } from "@opentui/core"

const audio = Audio.create({ autoStart: false })

audio.on("error", (error, context) => {
  console.error(`${context.action}: ${error.message}`)
})

const sound = await audio.loadSoundFile("click.wav")
if (sound != null && audio.start()) {
  audio.play(sound, { volume: 0.8, pan: 0, loop: false })
}

audio.dispose()

Use setupAudio(options?) when you prefer a function wrapper around Audio.create(options?).

Engine options

OptionDefaultDescription
autoStartfalseCall start() during creation
sampleRate48000Engine sample rate
playbackChannels2Requested playback channel count
startOptionsdefaultsOptions passed to start() when auto-starting

Create a new Audio instance to change sampleRate or playbackChannels.

Sounds and voices

MethodDescription
loadSound(data)Decode Uint8Array or ArrayBuffer; returns AudioSound
loadSoundFile(path)Read and decode a file; returns Promise<AudioSound | null>
unloadSound(sound)Free a loaded sound and stop voices using it
play(sound, options?)Start a voice; returns AudioVoice | null
stopVoice(voice)Stop one active voice
group(name)Create or reuse a named group; group 0 is the default group
setVoiceGroup(voice, group)Move a voice to another group
setGroupVolume(group, volume)Set group volume
setMasterVolume(volume)Set master volume

play() accepts { volume, pan, loop, groupId }. Native playback clamps volume to 0..4 and pan to -1..1. The engine has 32 voice slots.

Starting audio

start() starts native playback and returns false when no output device is available. Use isStarted() to check native playback state.

For headless mixing, tests, benchmarks, or manual mixFrames() output, use startMixer(). It starts the mixer without opening a playback device. Use isMixerStarted() to check either mode.

Bun standalone executables

Bun embeds files in bun build --compile executables when they are imported with with { type: "file" }. Read the embedded file as bytes and pass it to loadSound() so audio decodes from memory instead of a file path.

import { file } from "bun"
import { Audio } from "@opentui/core"
import clickPath from "./click.wav" with { type: "file" }

const audio = Audio.create({ autoStart: false })
const clickBytes = await file(clickPath).bytes()
const click = audio.loadSound(clickBytes)

if (click != null && audio.start()) {
  audio.play(click)
}

Compile the app normally with bun build --compile ./app.ts --outfile app.

Node.js standalone executables

Coming soon.

Devices

Select devices before starting the engine:

const audio = Audio.create({ autoStart: false })
const devices = audio.listPlaybackDevices() ?? []
const device = devices.find((item) => item.isDefault) ?? devices[0]

if (device) {
  audio.selectPlaybackDevice(device.index)
}

if (!audio.start()) {
  console.error("No playback device available")
}

listPlaybackDevices() returns { index, name, isDefault }[] | null. Use clearPlaybackDeviceSelection() to return to the default device selection.

Mixing and analysis

MethodDescription
mixFrames(frameCount, channels=2)Mix into a Float32Array; mono downmixes, channels above stereo keep extras zero
getStats()Returns soundsLoaded, voicesActive, framesMixed, lockMisses, lastPeak, lastRms
enableTap(capacityFrames=8192)Enable a fixed-size native ring buffer of recent mixed frames
readTapFrames(frameCount, channels=2)Copy the latest tapped frames; returns { frames, framesRead }
disableTap()Free the tap buffer

The tap is disabled by default. When enabled, it stores the latest frames and overwrites old data; reads do not drain it. OpenTUI does not compute FFT natively. For spectrum visualization, read tap frames and run FFT in TypeScript.

Start options

start(options?) accepts low-level device options: periodSizeInFrames, periodSizeInMilliseconds, periods, performanceProfile, shareMode, noPreSilencedOutputBuffer, noClip, noDisableDenormals, noFixedSizedCallback, wasapiNoAutoConvertSrc, wasapiNoDefaultQualitySrc, alsaNoMMap, alsaNoAutoFormat, alsaNoAutoChannels, and alsaNoAutoResample.

performanceProfile uses 0 for low latency and 1 for conservative. shareMode uses 0 for shared and 1 for exclusive.

Lifecycle and errors

Use start(), startMixer(), stop(), isStarted(), isMixerStarted(), and dispose() for lifecycle. Audio emits started, mixerStarted, stopped, disposed, and error. Methods that fail return false or null and emit error with { action, status } context.