Skip to content

Composer API

haruki7049 edited this page Sep 27, 2026 · 5 revisions

Composer API

Composer(T) is a generic type for arranging and overlaying multiple Wave(T) instances at specific points in time, then mixing them into a single Wave(T).

Type parameter

T is the sample data type. Must match the T used by the Wave instances you want to compose. Supported values are f32, f64, f80, and f128.

const Composer = lightmix.Composer;

// Composer type for 64-bit floating-point samples
const MyComposer = Composer(f64);

Fields

Field Type Description
items []WaveInfo Waves and their start times, like std.ArrayList.items
capacity usize Number of WaveInfo slots allocated for items (do not modify)
allocator std.mem.Allocator Allocator that owns items
sample_rate u32 Samples per second (e.g., 44100)
channels u16 Number of audio channels (1 = mono, 2 = stereo)

WaveInfo

pub const WaveInfo = struct {
    wave: Wave(T),
    start_point: usize, // Sample index where this wave begins
};

start_point is measured in samples. For example, at 44100 Hz, start_point = 44100 places the wave at the 1-second mark. It must be a multiple of the composer's channel count.

Note: items is owned by allocator and may be reallocated by append, appendSlice and ensureUnusedCapacity, so do not hold on to it across those calls. Like std.ArrayList, a Composer must not be copied and used through both copies.

Migration note (0.26.0): the info field was replaced by items / capacity, and append_converted was removed. Convert waves with Wave(T).to_channels before appending them.


Creating a Composer

init

pub fn init(
    allocator: std.mem.Allocator,
    options: InitOptions,
) InitErrors!Self

Creates an empty Composer with no waves. Use append or appendSlice to add waves afterwards.

InitErrors

Error Cause
InvalidChannelCount options.channels is zero
InvalidSampleRate options.sample_rate is zero

InitOptions

Field Type Description
sample_rate u32 Samples per second
channels u16 Number of audio channels

Example

const allocator = std.heap.page_allocator;
var composer = try Composer(f64).init(allocator, .{
    .sample_rate = 44100,
    .channels = 1,
});
defer composer.deinit();

init_capacity

pub fn init_capacity(
    capacity: usize,
    allocator: std.mem.Allocator,
    options: InitOptions,
) (InitErrors || std.mem.Allocator.Error)!Self

Creates an empty Composer with room for capacity entries pre-allocated.


init_with

pub fn init_with(
    info: []const WaveInfo,
    allocator: std.mem.Allocator,
    options: InitOptions,
) (InitErrors || Wave(T).MixErrors || std.mem.Allocator.Error)!Self

Creates a Composer pre-populated with the provided WaveInfo slice. The slice is copied internally.

Every entry is validated: error.MismatchedWaveProperties if a wave's sample_rate or channels differs from options, and error.UnalignedChannelOffset if a start_point is not a multiple of options.channels.

Example

const info: []const Composer(f64).WaveInfo = &.{
    .{ .wave = wave_a, .start_point = 0 },
    .{ .wave = wave_b, .start_point = 44100 }, // wave_b starts after 1 second
};

var composer = try Composer(f64).init_with(info, allocator, .{
    .sample_rate = 44100,
    .channels = 1,
});
defer composer.deinit();

Freeing a Composer

deinit

pub fn deinit(self: *Self) void

Frees the memory allocated for items and invalidates self. Because it takes *Self, the composer must be declared with var. This does not free the individual Wave instances inside each WaveInfo—those must be freed separately.


Adding waves

append

pub fn append(
    self: *Self,
    waveinfo: WaveInfo,
) (Wave(T).MixErrors || std.mem.Allocator.Error)!void

Adds a single WaveInfo to the composer in-place. The internal array grows with amortized O(1) cost, like std.ArrayList.

Returns error.MismatchedWaveProperties if the wave's sample_rate or channels differs from the composer's, and error.UnalignedChannelOffset if start_point is not a multiple of the channel count.

Example

var composer = try Composer(f64).init(allocator, .{ .sample_rate = 44100, .channels = 1 });
defer composer.deinit();

try composer.append(.{ .wave = wave_a, .start_point = 0 });
try composer.append(.{ .wave = wave_b, .start_point = 44100 });

appendSlice

pub fn appendSlice(
    self: *Self,
    append_list: []const WaveInfo,
) (Wave(T).MixErrors || std.mem.Allocator.Error)!void

Adds multiple WaveInfo entries at once, in-place. Every entry is validated like append before anything is added.

Example

const extra: []const Composer(f64).WaveInfo = &.{
    .{ .wave = wave_c, .start_point = 88200 },
    .{ .wave = wave_d, .start_point = 132300 },
};
try composer.appendSlice(extra);

ensureUnusedCapacity

pub fn ensureUnusedCapacity(
    self: *Self,
    additional_count: usize,
) std.mem.Allocator.Error!void

Ensures room for at least additional_count more entries without reallocating.


Finalizing the composition

finalize

pub fn finalize(
    self: Self,
    options: Wave(T).mixOptions,
) (Wave(T).MixErrors || std.mem.Allocator.Error)!Wave(T)

Mixes all waves in the composer into a single Wave(T) and returns it. An empty composer yields an empty wave.

The algorithm:

  1. Validates every entry (MismatchedWaveProperties, UnalignedChannelOffset) and calculates the total length (furthest start_point + wave.samples.len; error.Overflow if it overflows usize).
  2. Allocates one silent destination buffer of that length.
  3. Mixes each wave directly into position, sample-by-sample, using options.mixer. No intermediate buffers are allocated.

Waves are not converted between channel counts; convert them with Wave(T).to_channels before appending. The default mixer is a plain sum that never clamps; pass .mixer = Wave(f64).saturating_mixing_expression to clamp to [-1.0, 1.0].

The returned Wave must be freed by the caller with deinit.

options is the same mixOptions used by Wave.mix. See Wave-API for details.

Example — basic sequence

var composer = try Composer(f64).init(allocator, .{ .sample_rate = 44100, .channels = 1 });
defer composer.deinit();

try composer.append(.{ .wave = note_c, .start_point = 0 });
try composer.append(.{ .wave = note_e, .start_point = 44100 });
try composer.append(.{ .wave = note_g, .start_point = 88200 });

const result = try composer.finalize(.{});
defer result.deinit();

Example — overlapping (chord)

Waves with the same or overlapping start_point are mixed together:

try composer.append(.{ .wave = note_c, .start_point = 0 });
try composer.append(.{ .wave = note_e, .start_point = 0 }); // same start → chord
try composer.append(.{ .wave = note_g, .start_point = 0 });

const chord = try composer.finalize(.{});
defer chord.deinit();

Streaming rendering

render_stream

pub fn render_stream(
    self: Self,
    options: StreamOptions,
) (InitErrors || Wave(T).MixErrors || std.mem.Allocator.Error)!BlockIterator

Returns a BlockIterator that renders the composition in fixed-size blocks, keeping peak memory low for long compositions. It produces the same samples as finalize with the same mixer. The caller must call deinit on the iterator.

StreamOptions

Field Type Default Description
mixer MixerFn Wave(T).default_mixing_expression Mixer for overlapping samples
block_size usize 0 Block size in samples. 0 selects 4096 rounded up to a multiple of the channel count; an explicit value must be a multiple of the channel count

Returns the same validation errors as finalize, plus UnalignedChannelOffset for an unaligned explicit block_size.

The iterator borrows the composer's entries: the composer and every wave in it must outlive the iterator, and append, appendSlice and ensureUnusedCapacity must not be called while it is in use.

Example

var it = try composer.render_stream(.{});
defer it.deinit();

while (it.next()) |block| {
    // `block` is a []const T valid until the next call to `next`
    consume(block);
}

Full example

const std = @import("std");
const lightmix = @import("lightmix");
const Wave = lightmix.Wave;
const Composer = lightmix.Composer;

pub fn main() !void {
    const allocator = std.heap.page_allocator;

    // Helper: generate a sine wave at a given frequency
    const gen = struct {
        fn sineWave(freq: f64, alloc: std.mem.Allocator) !Wave(f64) {
            var samples: [44100]f64 = undefined;
            for (0..samples.len) |i| {
                const t = @as(f64, @floatFromInt(i)) / 44100.0;
                samples[i] = 0.3 * @sin(freq * 2.0 * std.math.pi * t);
            }
            return Wave(f64).init(samples[0..], alloc, .{ .sample_rate = 44100, .channels = 1 });
        }
    };

    const note_c = try gen.sineWave(261.63, allocator); // C4
    defer note_c.deinit();
    const note_e = try gen.sineWave(329.63, allocator); // E4
    defer note_e.deinit();
    const note_g = try gen.sineWave(392.00, allocator); // G4
    defer note_g.deinit();

    var composer = try Composer(f64).init(allocator, .{ .sample_rate = 44100, .channels = 1 });
    defer composer.deinit();

    // Sequence: C → E → G
    try composer.append(.{ .wave = note_c, .start_point = 0 });
    try composer.append(.{ .wave = note_e, .start_point = 44100 });
    try composer.append(.{ .wave = note_g, .start_point = 88200 });

    const result = try composer.finalize(.{});
    defer result.deinit();

    const file = try std.fs.cwd().createFile("sequence.wav", .{});
    defer file.close();
    const buf = try allocator.alloc(u8, 10 * 1024 * 1024);
    defer allocator.free(buf);
    var writer = file.writer(buf);

    try result.write(.wav, &writer.interface, .{ .bits = 16, .format_code = .pcm });
    try writer.interface.flush();
}

See also

  • Wave-API — Creating and manipulating individual waveforms
  • Build-Helpers — Generating WAV files at build time

Clone this wiki locally