Skip to content

Repository files navigation

lightmix

lightmix is an audio processing library written by Zig-lang.

Why I create this

I created this project because I felt a disconnect between existing audio synthesis environments and the standard software development workflow I use every day.

  • From "Recording" to "Building": In many existing tools, exporting audio feels like a manual task. I often had to click a record button or write specific code to manage recording buffers, essentially capturing the output in real-time. I wanted a workflow where audio is treated as a build artifact—where running zig build run (or zig build) instantly produces a WAV file, just as it would a binary executable.
  • Integration with the Modern Toolchain: I found it cumbersome to set up dedicated runtimes or specialized IDEs just to generate sound. I wanted to use my preferred editor and the standard Zig toolchain without any external dependencies or complex server setups.

lightmix is my attempt to bridge these two worlds. It allows me to "build" sound with the same precision, automation, and simplicity that I expect from any other software project.

Design Philosophy & Scope

lightmix is designed around a clear set of architectural principles:

  • Audio as a Deterministic Build Artifact: The primary mission of lightmix is deterministic, reproducible audio synthesis during zig build or in CI pipelines (e.g., automated generation of sound effects, procedural ambient tracks, or game audio assets). Output is bit-identical and requires no sound server or audio hardware.

  • Minimalist Core (Unix Philosophy): lightmix focuses exclusively on foundational audio primitives:

    • Waveform buffer management and basic transformations (Wave(T)).
    • Timeline-based audio mixing and track arrangement (Composer(T)).
    • Accurate WAV encoding/decoding and build.zig integration (addWave).

    High-level DSP effects (such as reverb, flanger, or chorus) and specialized synthesizer sound presets are considered out of scope (Non-Goals) for the core library. They belong in separate domain libraries or user application code.

  • Playback as a Verification Helper: Real-time audio playback (lightmix_play.play(), addPlay) is provided strictly as a developer convenience to preview generated sounds during development. It is an auxiliary feature and must never block or compromise headless CI runs or core audio generation.

  • Flat Generic Typing: Audio samples are generic over comptime T: type (f64, f80, f128, with f32 planned once underlying codec support is ready). No single floating-point precision is prioritized; users choose the balance between precision and memory overhead.

  • In-Memory Buffer Model: lightmix adopts an explicit in-memory model where entire waveforms are held in memory buffers. For game sound effects and typical multi-minute tracks, this provides maximum simplicity, safety, and performance without the complexity of streaming pipelines.

  • Stateless Randomness & External PRNG: lightmix does not embed pseudo-random number generators or hidden global state. When synthesizing noise-based signals (e.g., explosions or wind), callers supply sample data directly (e.g., using std.Random.DefaultPrng), ensuring total control over random seeds and determinism.

  • Crash Noise & Clipping as Valid Sound Sources: Overdriven signals, clipping, and crash noise are treated as valid sound sources in themselves. lightmix never auto-normalizes, sanitizes, or aborts builds with errors on out-of-bounds sample values during synthesis and mixing; raw values are preserved as-is, leaving sample quantization behavior entirely to underlying format codecs (such as zigggwavvv). When writing PCM, the codec clamps finite out-of-range samples to [-1.0, 1.0], but NaN and infinite samples cannot be quantized, so the write fails with NonFiniteSample (a codec-level exception; IEEE float formats store them as they are).

  • Pure Synthesis with Multi-Format Ingestion: While pure mathematical and algorithmic synthesis is the library's core motivation, importing external audio sources (Wave(T).read) is an essential capability for sampling, mashups, and real-world game sound design. Currently, standard uncompressed WAV is implemented, with pure Zig decoding for compressed formats (FLAC and Ogg Vorbis) planned on the roadmap (#138, #301).

  • Unified Format Export & Cross-Format Metadata: lightmix aims to abstract audio export across multiple formats under a unified interface (wave.write(...)), paired with format-agnostic metadata support (such as loop points for game engines).

  • Strict Property Matching (Explicit over Implicit): lightmix strictly enforces matching sample rates and channel counts during mixing. Mismatches result in explicit errors (error.MismatchedWaveProperties) rather than hidden resampling or implicit channel coercion.

  • Pure Zig & Zero C Dependencies: The library adheres to a Pure Zig policy, avoiding C compiler toolchain or C library dependencies to ensure seamless cross-compilation across any target platform.

  • Roadmap toward Unmanaged Memory: While current structs hold allocator instances for simplicity, lightmix embraces a planned future evolution toward modern Zig 0.16 Unmanaged patterns (explicit allocators per operation) when breaking changes can be bundled.

  • External Parallelism via Build System: Wave(T) and Composer(T) are kept purely synchronous and single-threaded. Multi-asset parallelization is delegated entirely to the build system via zig build -j to avoid internal threading complexity.

  • Pragmatic Generation Strategy: Compiling native generator binaries in .ReleaseFast via addWave is a pragmatic choice for maximum generation speed. lightmix remains flexible and pragmatic about adopting alternative compilation or evaluation techniques if faster or more reliable methods emerge.

How to use

In build.zig, import lightmix from build.zig.zon using b.dependency():

const lightmix = b.dependency("lightmix", .{});

const lib_mod = b.createModule(.{
    .root_source_file = b.path("src/root.zig"),
    .target = target,
    .optimize = optimize,
});
lib_mod.addImport("lightmix", lightmix.module("lightmix")); // Add lightmix to your library or executable module.

You can find some examples in ./examples directory. If you want to copy an example, edit .lightmix = .{ .path = "../../.." } in its build.zig.zon.

Build-time Wave file generation

lightmix provides a helper function addWave in build.zig that allows you to generate and install Wave files during the build process.

addWave function in build.zig

The addWave function is a build-time helper that allows you to generate Wave files as part of your build process. This means you can write a Zig function that generates audio, and the build system will automatically create the WAV file when you run zig build.

How to use addWave

  1. First, create a module with a function that generates a Wave:
// In your src/root.zig or similar file
const std = @import("std");
const lightmix = @import("lightmix");

pub fn generate(init: std.process.Init) !lightmix.Wave(f64) {
    const allocator = init.arena.allocator();

    // Generate your audio data (example: 1 second of silence)
    const data: [44100]f64 = [_]f64{0.0} ** 44100;

    // Wave.init() creates a deep copy of the data
    // The original data array can be safely discarded after this call
    const wave: lightmix.Wave(f64) = try lightmix.Wave(f64).init(data[0..], allocator, .{
        .sample_rate = 44100,
        .channels = 1,
    });

    return wave;
}
  1. In your build.zig, use the addWave function:
const std = @import("std");
const l = @import("lightmix");

pub fn build(b: *std.Build) !void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const lightmix = b.dependency("lightmix", .{});

    // Create your module that contains the wave generation function
    const mod = b.createModule(.{
        .root_source_file = b.path("src/root.zig"),
        .target = target,
        .optimize = optimize,
        .imports = &.{
            .{ .name = "lightmix", .module = lightmix.module("lightmix") },
        },
    });

    // Use addWave to generate the wave file during build
    const wave = try l.addWave(b, mod, .{
        .func_name = "generate", // Name of your wave generation function (optional, defaults to "gen")
        .format = .{ .wav = .{
            .name = "result.wav", // Output filename (optional, defaults to "result.wav")
            .format_code = .pcm, // Wave format code (e.g., .pcm, .ieee_float)
            .bits = 16, // The bits depth for this wave (e.g., 8, 16, 24, 32)
            .use_fact = false, // Include fact chunk in WAV header (optional, defaults to false)
            .use_peak = false, // Include PEAK chunk in WAV header (optional, defaults to false)
            .peak_timestamp = 0, // Timestamp for PEAK chunk (optional, defaults to 0)
        } },
        .path = .{ .custom = "share" }, // Install directory (optional, defaults to "share")
    });

    // Add to the install step so it runs during `zig build`
    // You can use installWave helper or access wave.step directly
    l.installWave(b, wave);
    // Or: b.getInstallStep().dependOn(wave.step);
}
  1. Run zig build to generate the wave file. The file will be created in zig-out/share/result.wav (or your configured path).

addWave Options

  • func_name: The name of the function in your module that generates the Wave. The function must have the signature pub fn name(init: std.process.Init) !lightmix.Wave(T) where T is your chosen sample type (e.g., f64) (default: "gen").
  • path: The installation directory relative to the install prefix (default: .{ .custom = "share" }).
  • optimize: Optimization mode for the standalone wave generator executable (default: .ReleaseFast). Using addWave compiles a high-performance native generator binary (.ReleaseFast), enabling extremely fast audio synthesis during zig build without compiler comptime interpreter slowdowns.
  • format: Tagged union specifying output format options (.wav = WavOptions).
    • bits: Bit depth for the output WAV file (e.g., 16, 24, 32).
    • format_code: Audio encoding format (e.g., .pcm, .ieee_float).
    • use_fact: Whether to write a fact chunk in the WAV header (default: false).
    • use_peak: Whether to write a PEAK chunk in the WAV header (default: false).
    • peak_timestamp: Timestamp value (in Unix epoch seconds) written to the PEAK chunk when use_peak is true (default: 0). Use l.currentTimestamp(b) to stamp the current time (or SOURCE_DATE_EPOCH when set); note that a wall-clock value disables build caching of the generated wave.

You can find a complete example in ./examples/06-advanced/build-time-generation.

lightmix's types

Wave

Wave is a generic type function that accepts a sample type parameter. It contains PCM audio source with samples of the specified floating-point type.

When mixing waves, both waves must have identical sample_rate, channels, and sample length, or error.MismatchedWaveProperties will be returned.

Supported sample types: f64, f80, f128.

const allocator = std.heap.page_allocator; // Use your allocator
const data: []const f64 = &[_]f64{ 0.0, 0.0, 0.0 }; // This array contains 3 float numbers, then this wave will be made from 3 samples.

const wave: lightmix.Wave(f64) = try lightmix.Wave(f64).init(data, allocator, .{
    .sample_rate = 44100, // Samples per second.
    .channels = 1, // Channels for this Wave. If this wave has two channels, it means this wave is stereo.
});
defer wave.deinit(); // Wave samples are owned by the passed allocator, so you must free this wave.

You can write your Wave to a wave file, such as result.wav.

// First, create your Wave with a specific sample type
const wave = generate_wave(); // Returns a Wave(f64)

// Second, you must create a file, typed as `std.fs.File`.
const file = try std.Io.Dir.cwd().createFile(io, "result.wav", .{});
defer file.close(io);

// Wav file size calculation
const bits = 16;
const total_size = wave.size(.wav, .{ .bits = bits });

// Create a buffer for file writer
const buf = try allocator.alloc(u8, total_size);
defer allocator.free(buf);

// Create a std.fs.File.Writer variable from the file.
// It has an `interface` variable typed `std.Io.Writer`.
var writer = file.writer(io, buf);

// Then, write down your wave!!
try wave.write(.wav, &writer.interface, .{
    .bits = bits, // Bit depth for the output file
    .format_code = .pcm, // Format code (e.g., .pcm or .ieee_float)
});

Channel Conversion Helpers

Wave(T) provides convenient helper methods to convert channel counts:

  • to_mono(): Converts the wave to mono (1 channel). Averages multi-channel audio frames into a single channel.
  • to_stereo(pan): Converts a mono wave to stereo (2 channels) applying panning (-1.0 for hard left, 0.0 for center, 1.0 for hard right).
// Convert to mono
const mono_wave = try wave.to_mono();
defer mono_wave.deinit();

// Convert to stereo with panning (e.g., pan 50% right)
const stereo_wave = try wave.to_stereo(0.5);
defer stereo_wave.deinit();

Composer

Composer is a generic type function that accepts a sample type parameter (same as Wave). It contains a Composer(T).WaveInfo array, which contains a Wave(T) and the timing when it plays.

const allocator = std.heap.page_allocator; // Use your allocator
const wave = generate_wave(); // Returns a Wave(f64)

const info: []const lightmix.Composer(f64).WaveInfo = &.{
    .{ .wave = wave, .start_point = 0 },
    .{ .wave = wave, .start_point = 44100 },
};
var composer: lightmix.Composer(f64) = try lightmix.Composer(f64).init_with(info, allocator, .{
    .sample_rate = 44100, // Samples per second.
    .channels = 1, // Channels for the Wave. If this composer has two channels, it means the wave is stereo.
});
defer composer.deinit(); // Composer.items is owned by the passed allocator, so you must free this composer. Do not copy a composer and use both copies, as with std.ArrayList.

const result: lightmix.Wave(f64) = try composer.finalize(.{}); // Let's finalize to create a Wave(f64)!!
defer result.deinit(); // Don't forget to free the Wave data.

Zig Version & 1.0.0 Milestone

  • Language Target: Zig 0.16.0 (tracks Zig's minor version).
  • Aggressive Deprecation Policy: Given that Zig has not yet reached its major 1.0 release, deprecated language and library features are pruned quickly to stay aligned with modern Zig idioms.
  • 1.0.0 Release Milestone: lightmix 1.0.0 will be released when Zig itself reaches version 1.0.0 (see #89).

API Documentations

https://haruki7049.github.io/lightmix

About

Audio processing library written by Zig-lang

Resources

Code of conduct

Contributing

Stars

21 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages