opentui

Build terminal UIs with OpenTUI. Covers Core, frameworks, components, application APIs, testing, extensions, integrations, deployment, and public API lookup.

Install
npx skills add 'https://github.com/anomalyco/opentui/tree/main/packages/web/src/content'
Download bundle ↓
main · ac753b4Scanned 2026-09-15

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗

docs/application-apis/audio-streaming.mdx

docs/application-apis/audio-streaming.mdxBrowse 89 files
View on GitHub
← Back to SKILL.md

title: Streaming audio description: Play bounded PCM, MP3, or FLAC streams through the native audio mixer skill: entry: true intents: [audio-streaming, audio-stream, pcm, f32le, radio, mp3, flac, icy, backpressure, reconnect]

Streaming audio

OpenTUI streams PCM, MP3, or FLAC bytes into the native audio mixer. Streaming starts before the complete source arrives.

Use streaming for generated audio, external decoders, long media, or internet radio. Use loaded sounds for short, reusable audio.

Play decoded PCM

Pass format: "pcm", sampleFormat: "f32le", sampleRate, and channels to audio.playStream(). The source is a ReadableStream<Uint8Array> or AsyncIterable<Uint8Array>, just like encoded audio. Playback returns the same AudioStream with the same controls, metadata, statistics, and lifecycle.

import { Audio, type AudioStream } from "@opentui/core"

const audio = Audio.create({ autoStart: false })
audio.on("error", (error) => console.error(error))
let stream: AudioStream | undefined

async function* tone(): AsyncGenerator<Uint8Array> {
  const framesPerChunk = 441
  const chunk = new Uint8Array(framesPerChunk * 4)
  const view = new DataView(chunk.buffer)
  for (let offset = 0; offset < 44100; offset += framesPerChunk) {
    for (let frame = 0; frame < framesPerChunk; frame++) {
      const sample = Math.sin((2 * Math.PI * 440 * (offset + frame)) / 44100) * 0.15
      view.setFloat32(frame * 4, sample, true)
    }
    yield chunk
    // Reuse the buffer only after the generator resumes.
  }
}

try {
  if (!audio.start()) throw new Error("No playback device is available")
  stream = await audio.playStream(tone(), {
    format: "pcm",
    sampleFormat: "f32le",
    sampleRate: 44100,
    channels: 1,
    buffer: { capacityMs: 500, startupMs: 20, resumeMs: 20 },
  })
  stream.on("error", (error) => console.error(error))
  await stream.closed
} finally {
  try {
    stream?.dispose()
    await stream?.closed
  } finally {
    audio.dispose()
  }
}

PCM format and ownership

  • Each sample is a finite 32-bit float encoded in little-endian byte order, nominally in [-1, 1]. Input is interleaved: mono is one sample per frame; stereo is left, right, left, right.
  • channels is 1 or 2. sampleRate is an integer from 8000 through 192000 Hz. The engine rate must be in that range too. Native conversion duplicates mono into stereo and resamples to the engine rate, preserving conversion history between chunks.
  • Byte chunks may split samples or frames at any boundary. OpenTUI carries incomplete input between reads. EOF with a partial final frame is an error. Empty chunks are accepted.
  • Do not mutate, transfer, or detach a chunk while OpenTUI consumes it. An async generator may reuse its yielded buffer when the generator resumes. A ReadableStream producer must not reuse a queued buffer before it is consumed. Shared backing memory is rejected.

For generated or captured Float32Array samples, encode bytes explicitly rather than relying on the machine's native byte order:

async function* pcmBytes(source: AsyncIterable<Float32Array>): AsyncGenerator<Uint8Array> {
  for await (const samples of source) {
    const bytes = new Uint8Array(samples.length * 4)
    const view = new DataView(bytes.buffer)
    for (let i = 0; i < samples.length; i++) {
      view.setFloat32(i * 4, samples[i], true)
    }
    yield bytes
  }
}

Pass pcmBytes(source) to playStream() with the source's sample rate and channel count. For direct capture reads, encode only the first framesRead * channels samples.

An external decoder's f32le byte pipe can be passed directly as the source. Applications own decoder processes and must stop them on cancellation. OpenTUI cancels the reader or returns the iterator; the source must release its resources in cancel(), iterator cleanup, or a connector's close() callback. Make pending external reads respond to cancellation too.

PCM buffering and completion

PCM uses the shared buffer defaults: capacityMs: 2000, startupMs: 1000, and resumeMs: 1000. The example explicitly lowers them to 500, 20, and 20 milliseconds for low latency.

PCM uses the same native worker and 256 KiB input queue as MP3 and FLAC. The worker converts input in batches of at most 2048 frames into the output ring. A full output ring pauses the worker; a full input queue backpressures the source. Accepted bytes need not already be converted. Native code retains no JavaScript pointers.

Source EOF automatically flushes the demuxer and signals the worker to finish conversion, including its tail, then drains queued audio. Await stream.closed for terminal cleanup; there is no separate write or end call. closed never rejects, so attach an error listener to observe playback failures. It resolves after cancellation and errors too.

Start the audio device first, or call audio.startMixer() and consume audio.mixFrames() regularly. A stopped mixer cannot drain queued audio. Completion means the mixer consumed the stream, not that the device physically presented the final sample.

Use the existing setVolume(), setPan(), setGroup(), and dispose() methods. Input format stays fixed for the stream's lifetime. For a track switch or rate change, dispose the old stream and open another source. Streaming has no public pause, resume, clear, write, or end methods.

The same PCM options work with playStreamUrl() and playStreamSource(), including custom demuxers where supported. PCM reconnect is not supported: passing reconnect rejects before the source opens.

PCM uses the shared statistics: bytesReceived counts accepted input bytes, including a pending partial frame; framesDecoded counts converted engine-rate output, including the conversion tail. There is no separate input-frame counter.

Run the generated-tone example from the examples package with bun src/pcm-playback.ts. Add --headless to exercise conversion and mixing without an output device.

Play a URL

The engine does not start output when it creates a stream. Start a playback device first, or consume headless output with mixFrames().

import { Audio, AudioStreamError, type AudioStream } from "@opentui/core"

const audio = Audio.create({ autoStart: false })
const controller = new AbortController()
const stopTimer = setTimeout(() => controller.abort(), 30_000)
let stream: AudioStream | null = null

function reportSetupError(error: unknown): void {
  if (error instanceof AudioStreamError) {
    console.error(`${error.context.action}: ${error.message}`)
  } else if (!(error instanceof DOMException && error.name === "AbortError")) {
    throw error
  }
}

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

try {
  if (!audio.start()) throw new Error("No playback device is available")

  stream = await audio.playStreamUrl("https://localhost/radio", {
    signal: controller.signal,
    reconnect: { maxRetries: 5 },
  })

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

  await stream.closed
} catch (error) {
  reportSetupError(error)
} finally {
  clearTimeout(stopTimer)
  if (stream != null && stream.state !== "ended" && stream.state !== "errored" && stream.state !== "disposed") {
    stream.dispose()
  }
  await stream?.closed
  audio.dispose()
}

Audio and AudioStream inherit Node's EventEmitter. Attach the Audio error listener before start() or stream setup.

Attach the AudioStream error listener immediately after setup resolves. Later stream errors emit asynchronously.

Stream setup errors reject the entry-method promise because the stream is not available for a listener yet. An unhandled later error event throws.

A control method can return false and schedule an error event. Without a listener, the event can throw after the caller receives false.

Choose an entry method

The three entry methods own different source policies.

MethodSource and ownership
playStream(source, options?)Reads one ReadableStream<Uint8Array> or AsyncIterable<Uint8Array> to EOF
playStreamUrl(url, options?)Owns Fetch, HTTP validation, ICY metadata, and optional reconnects
playStreamSource(connector, options?)Opens custom connections and creates a demuxer for each connection

playStream() is one-shot. It does not accept request, reconnect, content-type, or metadata-encoding options.

playStreamUrl() accepts a string or URL. It creates a new response body and ICY demuxer for each reconnect.

playStreamSource() accepts an AudioStreamConnector<I>. Its demuxer(info) callback can create transport-specific framing for each connection.

All methods support format: "pcm", format: "mp3", and format: "flac". The default is "mp3".

The promise resolves after native playback setup is ready. Encoded streams must probe their decoder first; PCM already has an explicit input format. The stream is usually still in buffering state at that time.

Shared options

OptionDefaultConstraint or effect
format"mp3""pcm", "mp3", or "flac"
sampleFormatNoneRequired for PCM; only "f32le"
sampleRateNoneRequired for PCM; integer from 8000 through 192000 Hz
channelsNoneRequired for PCM; 1 or 2
volume1Native playback clamps it to 0..4
pan0Native playback clamps it to -1..1
groupId0Must be an unsigned 32-bit ID for a group in the owning engine
buffer.capacityMs2000Decoded PCM capacity
buffer.startupMs1000Initial playback threshold
buffer.resumeMs1000Resume threshold after an underrun
maxProbeBytes1048576Maximum encoded bytes inspected during startup; unused for PCM
signalNoneCancels setup, source work, reconnect delays, and playback

The three buffer durations and maxProbeBytes must be positive unsigned 32-bit integers. Startup and resume durations cannot exceed capacity.

sampleFormat, sampleRate, and channels are PCM-only options. Supplying them for an encoded format rejects setup.

All formats produce stereo interleaved Float32 PCM at audio.sampleRate. PCM conversion requires an engine rate from 8000 through 192000 Hz. The maximum output capacity is 268,435,452 stereo frames.

Each stream uses one of the engine's 32 combined loaded-voice and stream slots.

Start, underrun, and EOF behavior

Decoding or conversion fills the PCM ring before playback crosses startupMs. Calls to mixFrames() return silence for that stream below the initial threshold.

After playback starts, starvation changes the stream to buffering and increments underruns once. Playback resumes when PCM reaches resumeMs.

If input reaches EOF with some PCM, playback can start or resume below either threshold. It drains the final frames before the stream ends.

Clean source EOF first flushes the demuxer. OpenTUI then finishes decoding or flushes the PCM converter and waits for output to drain.

ended and closed can remain pending when no output consumes the decoded ring. Call start(), or call startMixer() and mixFrames() at a regular cadence.

During a reconnect, a stream that already started can play PCM left in its decoded ring. Controls, counters, and the native voice slot remain in the same stream session.

Buffering and backpressure

OpenTUI bounds these internal layers:

LayerBound
Native input bytes256 KiB per stream
Native output PCMbuffer.capacityMs, up to 268,435,452 stereo frames
Native PCM conversion2048 input frames per batch plus one partial frame
JavaScript source demandOne active read() or iterator next()

OpenTUI copies the current source chunk into the native input queue before it requests the next chunk. A full input queue applies backpressure to that chunk. buffer.capacityMs bounds the output ring, not audio also waiting in the input queue or worker batch.

A blocked native write retries at 5 ms intervals. This polling exists only while that write has no space.

An empty input chunk yields with a zero-delay timer. This prevents an endless empty source from blocking cancellation.

OpenTUI does not limit the size of one supplied chunk. It also does not limit one custom demuxer output or the number of outputs in its iterable.

Source implementations can maintain their own queues before OpenTUI calls read() or next(). Those external queues are outside OpenTUI's bounds.

Readiness and EOF use short-lived polling. The stream does not keep a persistent idle statistics timer.

URL response policy

playStreamUrl() uses contentTypePolicy: "validate" by default.

FormatAccepted Content-Type values
MP3audio/mpeg, audio/mp3, application/mp3, application/octet-stream, or missing
FLACaudio/flac, audio/x-flac, application/octet-stream, or missing
PCMaudio/pcm, application/octet-stream, or missing

Validation ignores case and parameters such as charset=binary. It evaluates every initial and replacement response before native stream allocation or byte ingestion.

Use contentTypePolicy: "ignore" when the server has an incorrect label. Audio input validation still applies. For PCM, headers do not replace the required sample format, rate, and channel options.

A callback policy receives { format, contentType, status, url } and must return a boolean. The url is the effective response URL after redirects.

The request option accepts RequestInit except body and signal. OpenTUI also strips those two fields at runtime.

Use the stream-level signal option for cancellation. Request headers and other request options apply again to every reconnect.

ICY metadata

URL streams add Icy-MetaData: 1 unless request.headers already contains that header. Set the header to 0 to disable negotiation explicitly.

OpenTUI copies every icy-* response header into an immutable metadata snapshot. Header names become lowercase.

A non-negative safe integer icy-metaint controls in-band framing. A positive value enables metadata blocks, while zero exposes headers without framing.

OpenTUI does not infer framing from a URL, content type, server name, or station header. An invalid or ambiguous interval rejects the response.

getMetadata() returns the latest { format: "icy", headers, fields } snapshot or null. In-band field names keep their source case.

Unknown fields remain in the snapshot. Zero-length blocks, invalid field text, and repeated equivalent fields do not emit a change.

ICY does not define one text encoding. OpenTUI defaults to ISO-8859-1, which the Encoding Standard decodes as windows-1252.

Set metadataEncoding when a station uses another encoding. OpenTUI validates the encoding before it sends a request.

Metadata is untrusted text. Remove terminal control characters before display, and do not open a metadata URL without validation.

Metadata follows response ingestion, not audible playback. A title can arrive before the related decoded PCM becomes audible.

Events are asynchronous and rapid changes can coalesce. getMetadata() always returns the latest snapshot.

Metadata found during setup remains available after the promise resolves. OpenTUI schedules its event so an immediate listener can observe it.

During reconnect, the old snapshot remains until the replacement demuxer starts. Replacement ICY fields replace the old fields.

A replacement response without ICY headers sets metadata to null and emits metadata with null.

Reconnect URL streams

Reconnect is opt-in for MP3 and FLAC. Pass a reconnect object to enable it. PCM rejects this option before opening a URL or custom source.

OptionDefaultConstraint or effect
maxRetriesInfinityConsecutive retries for one outage
initialDelayMs1000First retry delay
maxDelayMs15000Delay cap
backoffFactor2Exponential factor, at least 1
retryOnEndfalseReconnect after clean EOF and PCM drain
retryNoneOverrides retry classification or delay

Use a finite maxRetries and an AbortSignal for unattended streams. The default retry count can keep initial setup pending without a limit.

URL streams retry these cases by default:

  • Fetch failures
  • A successful response without a body
  • HTTP 408, 425, or 429
  • HTTP status values from 500 through 599
  • A response body that fails before clean EOF

Other HTTP responses and content-type failures stop by default. Decoder failures, native creation failures, and demuxer push() failures are terminal.

Valid Retry-After seconds or dates replace normal backoff, up to maxDelayMs.

reconnect.retry(error, context) receives the next attempt, maxRetries, and a phase of "connect" or "read".

Return false to stop. Return {} to keep a transport delay or normal backoff.

Return { delayMs } to set a finite non-negative integer delay. OpenTUI caps that value at maxDelayMs.

The callback can override default URL response classification. OpenTUI does not call it for a clean EOF retry from retryOnEnd.

Initial retries occur while playStreamUrl() is pending. They call retry, but they do not emit reconnecting because callers do not have the stream yet.

After a replacement decoder becomes ready, the consecutive attempt number and retry budget reset. A later outage starts at attempt 1 again.

getStats().reconnectAttempts remains cumulative. It includes retries that occurred during initial setup and all later outages.

Use a custom connector

An AudioStreamConnector<I> opens one connection at a time:

interface AudioStreamConnectContext {
  readonly signal: AbortSignal
  readonly attempt: number
}

interface AudioStreamConnection<I> {
  readonly body: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>
  readonly info: I
  close?(): void | Promise<void>
}

interface AudioStreamConnector<I> {
  connect(context: AudioStreamConnectContext): Promise<AudioStreamConnection<I>>
}

The initial attempt is 0. Each consecutive reconnect gets the next value until a decoder becomes ready.

Connector and body-read failures are retryable by default when reconnect exists. The same retry options and callback rules apply.

The info value goes to the connection's demuxer factory. Return undefined when the transport has no connection metadata.

Use a custom demuxer

playStream() accepts demuxer: () => AudioStreamDemuxer<M>. playStreamSource() accepts demuxer: (info) => AudioStreamDemuxer<M> | null.

Each connector attempt receives a fresh demuxer. A demuxer has this interface:

type AudioStreamDemuxOutput<M> = { type: "audio"; data: Uint8Array } | { type: "metadata"; metadata: M | null }

interface AudioStreamDemuxer<M> {
  readonly initialMetadata: M | null
  push(chunk: Uint8Array): Iterable<AudioStreamDemuxOutput<M>>
  flush(): Iterable<AudioStreamDemuxOutput<M>>
  abort?(reason: unknown): void
}

OpenTUI consumes outputs in iterable order. It sends audio outputs to the decoder or PCM converter and publishes metadata outputs. For format: "pcm", demuxer audio outputs contain f32le bytes and may split samples or frames.

Clean EOF calls flush() and consumes all flush outputs before native EOF. An interruption calls abort() instead of flush().

A push() exception is terminal. A flush() exception means truncated input and follows the reconnect read policy.

Custom metadata snapshots should be immutable. The demuxer controls equivalent-update suppression for its metadata type.

Use createIcyStreamDemuxer() for ICY framing on a non-HTTP transport. It accepts metadataInterval, metadataEncoding, and optional headers.

The public ICY demuxer uses the same windows-1252 default. It copies headers and changes their names to lowercase.

Connection cleanup

OpenTUI waits for an active resource acquisition to finish before it cleans that connection.

Cleanup first calls demuxer.abort() when the demuxer did not finish. It then cancels the reader or returns the async iterator.

OpenTUI calls connection.close() after it requests source release. It waits for source and connection cleanup together.

A reconnect waits for the previous connection cleanup before it opens the replacement. This prevents two custom connections from overlapping.

Terminal shutdown gives uncooperative cleanup 50 ms of grace. The closed promise can then resolve while an external cleanup promise remains pending.

OpenTUI calls close() at most once for each returned connection. It ignores exceptions from cleanup callbacks.

dispose() can throw AudioStreamError with action destroy when native close fails. The stream retains its native ownership so a later dispose() can retry.

Members, state, and statistics

AudioStream memberBehavior
formatResolved "pcm", "mp3", or "flac" value
stateLatest cached lifecycle snapshot
getStats()Refreshes native state and returns current or final statistics
getMetadata()Returns the latest metadata snapshot or null
setVolume(volume)Sets volume and returns boolean
setPan(pan)Sets pan and returns boolean
setGroup(groupId)Moves the stream and returns boolean
dispose()Cancels source work, reconnects, and native playback
closedResolves after terminal event delivery and bounded cleanup

state can be initializing, buffering, playing, reconnecting, ended, errored, or disposed. Read getStats() for an explicit native refresh.

Controls remain active across reconnects. They return false after terminal cleanup or when native control fails.

getStats() returns these fields:

FieldMeaning
stateRefreshed lifecycle state
sampleRate, channelsOutput format: engine sample rate and stereo
bufferedFrames, capacityFramesCurrent output ring use and capacity
bufferedDurationMsCurrent buffered output duration
bytesReceivedAudio bytes accepted from demuxer outputs
framesDecoded, framesPlayedCumulative produced and consumed output frames
underrunsStarvation transitions after playback starts
reconnectAttemptsCumulative reconnect attempts

bytesReceived excludes framing and metadata bytes. For PCM it includes bytes held in a pending partial frame. Integer-divide by channels * 4 using the input channel count to recover the number of complete input frames received.

framesDecoded means decoded frames for encoded formats and converted output frames for PCM. PCM output includes conversion delay and the flushed filter tail. framesPlayed excludes underrun silence. Frame counters are bigint and use the engine rate; buffered duration excludes device buffers. Final statistics and metadata remain readable after natural completion.

Events and errors

EventPayload and meaning
metadataLatest metadata value or null
reconnecting{ attempt, delayMs, maxRetries, error }
endedClean source and decoded PCM completion
error(error, { action, status?, errorCode?, attempt? })
disposedExplicit, signal, or parent disposal

Terminal ended, error, and disposed events are asynchronous and mutually exclusive. closed resolves after the terminal listener runs, even if that listener throws.

closed never rejects. An unhandled error event still throws through normal EventEmitter behavior.

The exact AudioStreamAction values are:

  • fetch
  • response
  • source
  • demuxer
  • create
  • write
  • end
  • restart
  • stats
  • decoder
  • destroy
  • setVolume
  • setPan
  • setGroup

Transport, response, demuxer, native creation, and decoder setup failures reject with AudioStreamError. JavaScript option validation rejects with TypeError or RangeError.

Invalid source chunks reject or emit a TypeError. Cancellation uses an AbortError and becomes disposed after setup.

The worker detects non-finite PCM samples and EOF with a partial input frame; either terminates playback with AudioStreamError. Accepting input bytes does not mean they have passed conversion. PCM follows the same setup rejection and asynchronous terminal-event rules as encoded streams.

Starting a stream after its owning Audio was disposed rejects with Error. Disposing the owner also disposes pending and active streams.

Unsupported operations

Built-in streaming decoders do not support WAV, AAC, Ogg, Opus, or HLS playlists. Applications can decode externally and supply PCM bytes instead. Streaming has no seeking, pause, resume, clear, or public restart operation.

The restart error action belongs to internal reconnect work. It is not an AudioStream method.

Streams use the combined master tap. OpenTUI does not expose an isolated stream tap.

Next

Referenced from SKILL.md