audio

tape
import audio from "audio";
import fs from "fs";
import io from "io";

fn main() -> i32 {
    let data: string = fs.read_file("ding.wav") or { return 1; };
    let ding: audio.Sound = audio.decode_wav(data) or {
        io.println("cannot read the sound: {err}");
        return 1;
    };
    audio.play(ding) or { io.println("no sound: {err}"); };
    return 0;
}

audio plays sound on the system’s default output. The system mixes your program with every other one, and the user sets its volume in their own mixer (Windows’ Volume Mixer, their desktop’s sound settings, Leivos’s top bar), where it shows by its name. There are two ways to play:

  • a clip: a short sound you have in memory, played once, at once, over whatever else is playing: clicks, notifications, game effects;
  • a stream: samples you write as you make them: music from a decoder, a synthesizer, a game’s own mix.

Sounds and formats

A Sound is 16-bit samples in memory: rate frames a second, channels samples a frame (1 or 2, left then right), samples the data. It is a view: it owns nothing.

tape
pub struct Sound { rate: i64; channels: i64; samples: []const i16; }
pub struct Format { rate: i64; channels: i64; sample: Sample = Sample.I16; }
pub enum Sample : u8 { I16 = 1; F32 = 2; }

A stream’s Format says what you will write: 16-bit integers (full scale ±32767) or f32 (full scale ±1.0). Any rate from 8000 to 96000 and one or two channels work; the system converts to what the device takes.

Clips

FunctionDoes
play(sound, gain = 1.0)Play sound once and return at once; the first play loads it, later plays reuse it
load(sound) -> ClipHand the sound to the system (copied: the Sound may go away after)
c.play(gain = 1.0)Play the clip from its start, over anything playing, itself included
c.stop()Stop every play of the clip
c.free()Stop it and release it

play keeps the clip of each sound it has played, so use it for sounds that live as long as the program (a decoded file you keep, a sound in a global). For a sound you are about to drop, load it and free the clip. A clip is at most MAX_CLIP_SECONDS (10) long: use a stream for longer sounds. A program plays up to MAX_VOICES (8) clips at once; a ninth stops the one that started first. (On Leivos the limit is the machine’s: 16 clips over every program.)

tape
var click: audio.Clip = audio.load(click_sound) or { return 1; };
click.play(0.5) or { };
// ...
click.free();

Streams

FunctionDoes
open(format, buffer_ms = 100) -> StreamA stream on the default output, paused, holding buffer_ms of sound
s.start(), s.pause()Play, or stop where it is (what is written stays)
s.write_i16(samples), s.write_f32(samples)Queue all of samples, waiting while the buffer is full
s.try_write_i16(samples) -> i64, s.try_write_f32(samples) -> i64Queue the whole frames that fit now, never waiting; how many
s.free_frames() -> i64Frames a try_write would take now
s.played() -> i64Frames played since open
s.drain()Wait until everything written is played, then pause
s.set_volume(gain)The stream’s level, 0.0 silent to 1.0 as written; changes ramp smoothly
s.handle() -> i64A handle to watch with ui.watch, ready when the stream can take more
s.close()Stop at once and release the stream (drain first to hear the end)

The buffer is the latency: what you write is heard up to buffer_ms later. A stream starts paused so you can fill it first: write what fits, then start.

tape
import audio from "audio";
import math from "math";

var block: [960]i16;      // 10 ms of 48 kHz stereo

fn main() -> i32 {
    var s: audio.Stream = audio.open(audio.Format { rate: 48000; channels: 2; }) or { return 1; };
    var t: i64 = 0;
    while (t < 48000 * 2) {                      // two seconds of 440 Hz
        for (i in 0..480) {
            let v: f64 = math.sin_f64(math.TAU * 440.0 * ((t + i) as f64) / 48000.0) * 8000.0;
            block[i * 2] = v as i16;
            block[i * 2 + 1] = v as i16;
        }
        s.write_i16(block[0..960]) or { return 1; };
        if (t == 0) { s.start() or { return 1; }; }   // the first block is in: play
        t = t + 480;
    }
    s.drain() or { return 1; };
    s.close();
    return 0;
}

The samples are interleaved: frame n is samples[n * channels] (left) and samples[n * channels + 1] (right). A slice whose length isn’t a whole number of frames is a broken contract (a trap), as is writing f32 to an I16 stream.

If your program writes too slowly the stream plays silence until the next frames come, then goes on; played() counts only what was really played. A waiting write on a paused stream that the samples can never fit into would wait forever: it traps instead, telling you to start first.

Feeding a stream from a window

write_i16 waits, which would freeze a window. In a ui program, watch the stream’s handle and write what fits each time it is ready:

tape
import audio from "audio";
import ui from "ui";

var music: audio.Stream;
var next_block: [4800]i16;

fn feed(h: i64) {
    let free: i64 = music.free_frames() or { return; };
    let n: i64 = fill(next_block[0..4800], free);    // your samples, up to `free` frames
    music.try_write_i16(next_block[0..n * 2]) or { };
}
tape
music = audio.open(audio.Format { rate: 48000; channels: 2; }) or { return 1; };
music.start() or { return 1; };
ui.watch(music.handle(), feed) or { return 1; };

WAV files

FunctionDoes
decode_wav(data) -> Sound or AudioErrorThe samples of a WAV file as a Sound, in new memory
free_wav(sound)Release what decode_wav allocated (and the clip play kept of it)

decode_wav reads PCM WAV at 8, 16, 24 or 32 bits and 32-bit float WAV, mono or stereo, at 8000 to 96000 Hz; anything else is InvalidWav. The reading itself is the wav module’s, which needs no audio system: use it alone when you only read WAV files.

Errors

tape
pub enum AudioError : u8 {
    NoDevice = 1: "no audio output device";
    Unavailable = 2: "the audio system is not available";
    Unsupported = 3: "audio format not supported by the device";
    DeviceLost = 4: "the audio device went away";
    TooLong = 5: "sound too long for a clip";
    InvalidWav = 6: "not a playable WAV file";
    OutOfMemory = 7: "out of memory";
}

A program that plays sound should go on without it: on a server, in a container or on a machine without speakers, open and load return NoDevice or Unavailable and nothing else changes. When the user switches outputs (plugs in headphones, picks another device), a stream moves to the new default by itself, with a short gap (on macOS, not yet: a stream stays on the output it opened on); DeviceLost means no output is left.

A gain outside 0.0..1.0, a rate or channel count out of range, or using a stream after close are broken contracts and trap.

Platforms

PlatformThrough
WindowsWASAPI, the system’s audio engine
LinuxALSA’s default device, which reaches PipeWire or PulseAudio; needs libasound.so.2 (without it, Unavailable)
macOSCoreAudio’s audio queues
Leivosaudiod, Leivos’s sound server

The audio library is audio100 (.dll, .so, .dylib), next to tape and next to your program like the other runtime libraries; on Leivos it is compiled into your program.

Use a stream from the thread that opened it. Recording, choosing among several outputs, and sounds embedded with #resource sound are not there yet.

Last modified: