Add the dependency
In your project’s ziran.toml; pin ref to a commit for reproducible builds.
[dependencies.audio]
git = "https://github.com/ziranlang/audio.git"
ref = "master"Overview
Build and use
Install Ziran plus the development/link libraries for libopus and
libsamplerate (libopus-dev and libsamplerate0-dev on Debian-family
Linux), then run:
make
build/bin/audio --help
build/bin/audio encode input.wav output.opus --bitrate 64000 --vbr on
build/bin/audio encode voice.wav voice.opus --bitrate 24000 --application voice --bandwidth wide --vbr constrained
build/bin/audio inspect output.opus
build/bin/audio decode output.opus decoded.wav
build/bin/audio transcode input.opus smaller.opus --bitrate 32000
build/bin/audio resample input.wav speech.wav --rate 16000
The CLI supports PCM16 and IEEE float32 RIFF WAV input, and mapping-family-0
Ogg Opus (mono/stereo) with the exact 19-byte version-1 identification header.
Other Opus header versions/extensions are outside this bounded profile.
Decoded WAV output is PCM16 at 48 kHz. resample
accepts either format and writes PCM16 WAV at the requested 8000–192000 Hz
rate. transcode decodes WAV/Opus and encodes Opus; it is a lossy re-encode
when its source is already Opus. Other audio formats belong to separate
modules; existing std/vorbis and std/vorbis_linux support is independent.
Encoder options are --bitrate in bits/second (6000–510000), --vbr
on|off|constrained, --bandwidth auto|narrow|wide|superwide|full,
--application audio|voice|lowdelay, --complexity 0–10 and --frame-ms
5, 10, 20, 40 or 60. Defaults are 96000 bps, VBR, automatic bandwidth,
audio application, complexity 10 and 20 ms. The bandwidth option is a cap;
libopus still chooses its actual bandwidth. Constrained VBR is a separate
rate-control setting. The six-kilohertz medium-band cap is omitted because
libopus can choose a wide-band CELT packet for that setting. Neither a bitrate target nor constrained VBR guarantees
a particular file size or quality.
Non-48-kHz input is converted with libsamplerate's SRC_SINC_BEST_QUALITY
before encoding. This is a band-limited sinc converter, including for 44.1→48
kHz. Float PCM keeps finite headroom through resampling/encoding; sinc can
overshoot a normalized transient. ValidPCM admits finite values in [-16,16].
PCM16 WAV and Kryon's PCM16 sink explicitly clip to [-1,32767/32768] at the
integer boundary. There is no implicit loudness normalization.
Files contain up to 200 ms of packets per Ogg page, bounded by the format's 255 lacing entries and 65025 body bytes. This amortizes page overhead for low-bitrate audio. The packet API retains individual frames for callers that need lower latency. On the tested synthetic three-second mono 440 Hz fixture, 16 kbps voice-mode output occupied 6877 bytes including 18 pages, versus 288044 bytes of PCM16 WAV. This is a fixture measurement, not a speech or music quality benchmark.
CLI operations validate/process into memory before touching the destination.
They stage output in an exclusively created OUTPUT.audio-part file beside
the destination and rename it only after a complete write/close. Errors preserve
an existing output. An existing staging file causes an error and is preserved.
Literal and resolved source/destination aliases are rejected. The CLI does
not play audio or open audio devices.
Callable modules
Exports are audio/types, audio/ogg, audio/wav, audio/opus,
audio/resample and audio/pipeline.
Full-pipeline native consumers declare links = ["opus", "samplerate", "m"] in their
root manifest's [package] table, or use equivalent linker flags in a custom
host. Ziran does not inherit system links from dependencies. Codec-only consumers need
only links = ["opus", "m"]; audio/opus does not import the resampler.
Types :: #import "audio/types"
Pipeline :: #import "audio/pipeline"
#import "std/vec"
EncodeAudio :: (pcm: *Types.PCM, output: *Vec(u8)) -> s32 {
options := Types.DefaultOptions()
options.bitrate = 64000
options.bandwidth = Types.Bandwidth.Full
return Pipeline.Encode(pcm, options, output)
}
PCM owns interleaved Vec(float32) samples plus rate:s32 and
channels:s32. Pass it by pointer. Ordinary vector cleanup releases sample
storage. SwapSamples(*PCM,*Vec(float32)) transfers sample ownership without
copying. Whole-buffer convenience calls leave their output unchanged on an
error and return a status from audio/types (ErrorText gives a short message):
audio/pipeline.Encode(*PCM, Options, *Vec(u8)) -> s32Decode([]u8, max_frames:s64, *PCM) -> s32Resample(*PCM, target_rate:s32, *PCM) -> s32, with distinct input/outputInspect([]u8, *Info) -> s32, clearingInfoon failureWavDecode([]u8, max_frames:s64, *PCM) -> s32WavEncode(*PCM, *Vec(u8)) -> s32
Info reports channels, 48000-Hz output rate, original-rate header hint,
logical frame count, pre-skip, signed Q8 gain and packet count. Decode applies
the header gain, discards pre-skip and honors final-granule end trim. Original
sample rate is informational and does not change the Ogg 48-kHz timebase.
Whole-buffer PCM is limited to 28800000 frames and encoded/input bytes to
256 MiB. max_frames can impose a smaller eager-decoding cap. Inspection
and incremental decode instead permit up to 24 hours at 48 kHz, without
allocating full PCM. Ogg packets/comments are bounded to 65536 bytes. These
limits are explicit capability boundaries, including on otherwise valid input.
Incremental APIs and ownership
DecoderOpen(*Decoder, input:[]u8, max_frames:s64) -> s32 validates the
single logical stream, then DecoderRead(*Decoder, output:[]float32,
frames:*s64) -> s32 fills caller-owned interleaved scratch. Output may be as
small as one frame. OK with zero returned frames means logical EOF. A
failed read may already have filled a prefix; discard that read on error.
DecoderRewind(*Decoder) -> s32 resets the codec over the previously validated
input without scanning the whole file again. DecoderClose(*Decoder) is
idempotent and releases native state. Scratch is bounded to one 120 ms Opus
packet plus one 65536-byte Ogg packet, regardless of track duration. Input
bytes are borrowed: keep them alive and unchanged until close.
EncoderOpen(*Encoder, channels:s32, Options) -> s32 opens a 48-kHz encoder.
EncoderPacket(*Encoder, pcm:[]float32, packet:[]u8, written:*s32) -> s32
requires exactly its configured frame size and caller-owned packet storage
(4096 bytes is sufficient for supported frame durations). It produces a raw
Opus packet; it does not attach Ogg headers or timing. Encoder.lookahead
is the required Ogg pre-skip. Close it with EncoderClose(*Encoder).
SincOpen(*SincState, channels, from_rate, to_rate) -> s32 selects best sinc.
SincProcess(*SincState, input:[]float32, output:[]float32, final:bool,
used:*s64, generated:*s64) -> s32 reports frame counts, not sample counts.
Buffers must be disjoint. Retain unconsumed input; on the final chunk use
final=true, then drain with an empty input until zero output. The state marks
itself ended after that drain. SincClose(*SincState) is idempotent. A whole
conversion and irregular streaming chunks share the same filter state/quality.
Live encoder/decoder/sinc states have one owner. Do not copy them, call them concurrently, or replace their borrowed storage. Use separate states for separate streams. The CLI conveniences are bounded whole-file operations; only the explicit state APIs stream PCM.
Android
The manifest pins the codec sources as source = true dependencies:
libopus (xiph/opus v1.5.2) and libsamplerate (libsndfile/libsamplerate
0.2.2). An NDK CMake build includes native/AudioCodecs.cmake and calls
ziran_audio_codecs("<libopus>" "<libsamplerate>") with the paths from
ziran pkg path libopus / ziran pkg path libsamplerate. It defines static,
position-independent opus and samplerate targets (float API; no programs,
tests or DNN extensions) to link into the app's native library. Generate the
app's C with Breeze's Android defines, --define ANDROID_BUILD plus the ABI's
__aarch64__, __arm__, __i386__ or __x86_64__; std file mapping and
renames need them on bionic.
make android-test (after make test) builds both libraries and the CLI with
NDK 28.2.13676358 at API 21 for arm64-v8a, armeabi-v7a, x86 and x86_64
(ANDROID_NDK_HOME, ANDROID_API and ANDROID_ABIS override this). With
ANDROID_SERIAL=SERIAL it runs inspect, decode, resample and encode on that
device and compares the output with the Linux CLI. On a Moto e6 play
(armeabi-v7a, Android 9), decoded and resampled PCM16 matched Linux exactly
and the device-encoded stream decoded on Linux at the source duration. The
other three ABIs are build-tested only.
Windows
The same recipe builds static libopus.a and libsamplerate.a for
x86_64 MinGW. With the pinned sources from ziran pkg path:
cmake -S native -B build/windows/native -DCMAKE_SYSTEM_NAME=Windows \
-DCMAKE_SYSTEM_PROCESSOR=AMD64 -DCMAKE_C_COMPILER=x86_64-w64-mingw32-gcc \
-DCMAKE_TRY_COMPILE_TARGET_TYPE=STATIC_LIBRARY -DCMAKE_BUILD_TYPE=Release \
-DOPUS_DIR="$(ziran pkg path libopus)" -DSAMPLERATE_DIR="$(ziran pkg path libsamplerate)"
cmake --build build/windows/native
The archives are build/windows/native/libopus/libopus.a and
build/windows/native/libsamplerate/src/libsamplerate.a (a CMake consumer
calls ziran_audio_codecs and links the opus target). Generate C with
--define _WIN32 --define __x86_64__; no other define is needed. libopus
keeps its default stack protector and _FORTIFY_SOURCE=2, so a MinGW link
adds GCC's -lssp after the archives: decoder-only consumers link
libopus.a -lssp -lm, full-pipeline consumers libopus.a libsamplerate.a
-lssp -lm. With -static -static-libgcc the programs import only
kernel32, msvcrt and advapi32.
make windows-test (after make test; WINDOWS_CC names the compiler)
builds both archives and two codec programs, then runs them in a private
Wine prefix under a private Xvfb display and compares them with the Linux
CLI. The decoder-only program streams nine mono/stereo fixtures through
DecoderOpen/DecoderRead in 997-frame chunks, rewinds with
DecoderRewind and requires an identical second pass. Its inspection
lines, five malformed-stream rejections and decoded frame counts matched
Linux; PCM16 differed by at most 2 LSBs against the system libopus build
(3 of 9 bit-exact). The full-pipeline program encoded voice.wav with the
CLI's 24 kbps voice settings to the same size as Linux; Linux decoded it to
the source duration. This was tested with Nix MinGW GCC 15.3.0 and Debian
Wine 10.0, not on Windows hardware. Windows sinc resampling still returns
Unsupported, so the pipeline accepts only 48-kHz input there. The audio
CLI itself is not built for Windows: Ziran's std/file_tree_linux has no
Win32 implementation.
Validation and target support
Ogg inspection checks capture/version, CRC, stream serial, page sequence, BOS/EOS, continuation/lacing, bounded tags, TOC durations, granule monotonicity, pre-skip and end trim. This bounded profile requires nonempty Ogg pages. It rejects truncation, trailing/chained/multiplexed streams, multichannel mappings and nonzero initial timeline offsets. Inspection validates the container and declared packet duration; libopus validates packet payloads as they are decoded. It does not claim to validate an unheard packet's compressed bitstream. Rewind assumes the validated bytes stayed unchanged.
Native codec and sinc execution is tested with Linux x86-64 C against the
system libopus 1.5.2 and libsamplerate 0.2.2, and on Android (see below). The
SRC_DATA C-long ABI is represented by isize on Unix C targets, including
32-bit Android. Windows sinc execution returns Unsupported before FFI,
because Windows C long is 32 bits. Windows x86_64 codec execution is
tested under Wine (see above); macOS codec execution is unverified. Kryon's
native provider plays Ogg Opus through these modules on Linux, Android
(Kryon commit 34e552d) and Windows MinGW (Kryon commit 34d0ce4, linking
libopus.a -lssp -lm); macOS still reports Unsupported, and browser
playback uses the browser's own WebAudio decoder. Codec FFI and sinc are not
portable VM implementations; reachable native calls reject bundling instead
of fabricating output. Portable Ogg/WAV modules execute from source and saved
IR in C, Go and .zx without codec libraries. The CLI's variadic libopus
controls require the C target; Go CLI/native codec builds are not supported.
Run make test for source/saved-IR native codec+CLI tests, portable VM/Go
execution, FFI rejection, streaming parity, alias rejection, transients,
malformed data, atomic output and FFmpeg interoperability. Python and FFmpeg
are test tools only. Strict voice/chirp PCM comparison forces FFmpeg's
libopus decoder (within 1 PCM16 LSB). FFmpeg's independent decoder also checks
duration and the CELT sine fixtures; SILK/resampler implementations can differ
without a container timing error. No listening score or universal compression
quality claim follows from these tests.
References: Opus public API 1.5, RFC 7845 Ogg Opus, libsamplerate state API.
audio/src/types.zi
types
Import with #import "audio/types".
OK :: 0Invalid :: 2Malformed :: 3Unsupported :: 4LimitExceeded :: 5NativeFailure :: 6AllocationFailed :: 7IOFailure :: 8PCM :: struct { samples: Vec(float32); rate: s32; channels: s32; }Interleaved normalized float samples. The caller owns the vector.
Info :: struct { channels: s32; rate: s32; original_rate: s32; frames: s64; pre_skip: s32; gain_q8: s32; packets: s64; }Bandwidth :: enum { Auto = 0; Narrow = 1; Wide = 2; Superwide = 3; Full = 4; }Application :: enum { Audio = 0; Voice = 1; LowDelay = 2; }Options :: struct { bitrate: s32; vbr: bool; constrained_vbr: bool; bandwidth: Bandwidth; complexity: s32; application: Application; frame_ms: s32; }DefaultOptions :: () -> OptionsMaxBytes :: 268435456Memory/work bounds apply before allocations. Rates are bounded to avoid enormous output for hostile headers. Whole-file APIs are intentionally bounded.
MaxFrames :: 28800000ValidPCM :: (pcm: *PCM) -> boolErrorText :: (code: s32) -> stringLittle :: (input: []u8, at: s64, count: s64) -> u64PutLittle :: (output: []u8, at: s64, count: s64, value: u64)Magic :: (input: []u8, at: s64, text: string) -> boolSwapSamples :: (pcm: *PCM, samples: *Vec(float32))Move ownership between caller vector and PCM without copying samples.
audio/src/ogg.zi
ogg
Import with #import "audio/ogg".
OggCRC :: (page: []u8) -> u32Ogg CRC-32 (non-reflected), with the checksum field treated as zero.
OggReader :: struct { input: []u8; at: s64; end: s64; body: s64; segment: s64; segments: s64; serial: u64; sequence: u64; granule: s64; flags: u8; packet: [65536]u8; packet_count: s64; done: bool; page_last: bool; last_complete: bool; }Bounded packet iterator. Borrowed input must outlive this record. A returned packet is valid until the next call; never retain a view over NextPacket.
NextPacket :: (reader: *OggReader) -> s32PacketFrames :: (packet: []u8) -> s32RFC 6716 TOC duration at the Ogg timebase (48 kHz), independent of FFI.
Inspect :: (input: []u8, info: *Info) -> s32AppendPage :: (output: *Vec(u8), packet: []u8, flags: u8, granule: s64, serial: u32, sequence: u32) -> s32One complete packet per page. Output is staged by the whole-file encoder.
PageBuilder :: struct { body: [65025]u8; laces: [255]u8; count: s64; segments: s64; }Efficient file muxing: accumulate up to 200 ms or 255 lacing entries in a page, amortizing the 27-byte header without changing Opus frame latency.
PageAdd :: (page: *PageBuilder, packet: []u8) -> s32PageFlush :: (page: *PageBuilder, output: *Vec(u8), final: bool, granule: s64, sequence: u32) -> s32audio/src/wav.zi
wav
Import with #import "audio/wav".
WavDecode :: (input: []u8, max_frames: s64, output: *PCM) -> s32Canonical RIFF WAVE PCM16 or IEEE float32 mono/stereo. Unknown chunks are skipped with padding, but duplicate fmt/data, RF64 and compressed WAV reject.
WavEncode :: (pcm: *PCM, output: *Vec(u8)) -> s32PCM16 output clips finite sinc/codec overshoots at the integer boundary.
audio/src/opus.zi
opus
Import with #import "audio/opus".
Encoder :: struct { handle: *void; channels: s32; frame_size: s32; lookahead: s32; }EncoderClose :: (encoder: *Encoder)EncoderOpen :: (encoder: *Encoder, channels: s32, options: Options) -> s32EncoderPacket :: (encoder: *Encoder, pcm: []float32, packet: []u8, written: *s32) -> s32Exactly one configured frame (48 kHz interleaved float). Caller retains buffers. Native encoder state must be closed once by its owning caller.
Decoder :: struct { handle: *void; reader: OggReader; info: Info; scratch: [11520]float32; pending_at: s64; pending_end: s64; decoded: s64; emitted: s64; gain: float64; start_at: s64; start_sequence: u64; }Incremental Ogg decoder: bounded 120 ms scratch, no full PCM allocation. The input byte view is borrowed until DecoderClose. This is single-owner, single-thread state; callers must never copy a live decoder or encoder.
DecoderClose :: (decoder: *Decoder)DecoderOpen :: (decoder: *Decoder, input: []u8, max_frames: s64) -> s32DecoderRewind :: (decoder: *Decoder) -> s32Rewind already validated, immutable borrowed input without rescanning it. Only the owning caller may mutate state; input must stay unchanged until close.
DecoderRead :: (decoder: *Decoder, output: []float32, frames: *s64) -> s32Returns OK and frames=0 only at exact logical EOF. Output is caller-owned, interleaved and may be as small as one frame; the unused suffix is unchanged.
Decode :: (input: []u8, max_frames: s64, output: *PCM) -> s32audio/src/pipeline.zi
pipeline
Whole-buffer preprocessing and Ogg Opus encoding. Codec-only consumers import opus without pulling in the sample-rate converter.
Import with #import "audio/pipeline".
Encode :: (pcm: *PCM, options: Options, output: *Vec(u8)) -> s32audio/src/resample.zi
resample
Import with #import "audio/resample".
SincData :: struct { input: *float32; output: *float32; input_frames: isize; output_frames: isize; input_used: isize; output_generated: isize; end_of_input: s32; ratio: float64; }Public libsamplerate SRC_DATA ABI: C long maps to isize on supported Unix C targets. Windows is rejected before creating or processing native state.
SincState :: struct { handle: *void; channels: s32; ratio: float64; ended: bool; }SincOpen :: (state: *SincState, channels: s32, from_rate: s32, to_rate: s32) -> s32SincClose :: (state: *SincState)SincProcess :: (state: *SincState, input: []float32, output: []float32, final: bool, used: *s64, generated: *s64) -> s32Caller supplies disjoint buffers. Respect both used and generated counts; keep feeding the unconsumed input, and drain with final=true until generated=0.
Resample :: (pcm: *PCM, target_rate: s32, output: *PCM) -> s32