this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

init: sample-by-sample dsp library

inspired by torvalds/AudioNoise. includes:
- biquad filters (lowpass, highpass, bandpass, notch, allpass)
- lfo oscillator (sine, triangle, sawtooth)
- wav file writer (no ffmpeg needed)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

zzstoatzz f2b7ae14

+510
+3
.gitignore
··· 1 + .zig-cache/ 2 + zig-out/ 3 + *.wav
+59
README.md
··· 1 + # noise 2 + 3 + sample-by-sample digital audio processing in zig. 4 + 5 + inspired by [torvalds/AudioNoise](https://github.com/torvalds/AudioNoise). 6 + 7 + ## philosophy 8 + 9 + - single sample in, single sample out 10 + - zero latency (no block processing) 11 + - IIR filters and delay lines only 12 + - no FFT, no complex transforms 13 + - learn by building 14 + 15 + ## what's here 16 + 17 + - **biquad** - second-order IIR filter (lowpass, highpass, bandpass, notch, allpass) 18 + - **lfo** - low frequency oscillator (sine, triangle, sawtooth) 19 + 20 + ## usage 21 + 22 + ```zig 23 + const noise = @import("noise"); 24 + 25 + // create a lowpass filter at 1kHz 26 + const lpf = noise.biquad.lowpass(1000, 0.707, 48000); 27 + var state: noise.State = .{}; 28 + 29 + // process samples 30 + for (input_samples) |sample| { 31 + const output = noise.biquad.step(lpf, &state, sample); 32 + // ... 33 + } 34 + ``` 35 + 36 + ## example 37 + 38 + ```bash 39 + zig build run 2>/dev/null | ffplay -f f32le -ar 48000 -ac 1 - 40 + ``` 41 + 42 + generates a 440Hz sine with lowpass filtering and tremolo. 43 + 44 + ## install 45 + 46 + ```zig 47 + // build.zig.zon 48 + .noise = .{ .url = "https://tangled.sh/@zzstoatzz.io/noise/archive/main" }, 49 + 50 + // build.zig 51 + const noise = b.dependency("noise", .{}).module("noise"); 52 + exe.root_module.addImport("noise", noise); 53 + ``` 54 + 55 + ## test 56 + 57 + ```bash 58 + zig build test 59 + ```
+34
build.zig
··· 1 + const std = @import("std"); 2 + 3 + pub fn build(b: *std.Build) void { 4 + const target = b.standardTargetOptions(.{}); 5 + const optimize = b.standardOptimizeOption(.{}); 6 + 7 + const mod = b.addModule("noise", .{ 8 + .root_source_file = b.path("src/root.zig"), 9 + .target = target, 10 + .optimize = optimize, 11 + }); 12 + 13 + const tests = b.addTest(.{ .root_module = mod }); 14 + const run_tests = b.addRunArtifact(tests); 15 + 16 + const test_step = b.step("test", "run unit tests"); 17 + test_step.dependOn(&run_tests.step); 18 + 19 + // example executable 20 + const example = b.addExecutable(.{ 21 + .name = "example", 22 + .root_module = b.createModule(.{ 23 + .root_source_file = b.path("src/example.zig"), 24 + .target = target, 25 + .optimize = optimize, 26 + .imports = &.{.{ .name = "noise", .module = mod }}, 27 + }), 28 + }); 29 + b.installArtifact(example); 30 + 31 + const run_example = b.addRunArtifact(example); 32 + const run_step = b.step("run", "run example"); 33 + run_step.dependOn(&run_example.step); 34 + }
+11
build.zig.zon
··· 1 + .{ 2 + .name = .noise, 3 + .version = "0.1.0", 4 + .fingerprint = 0xfda99629842e4ed0, 5 + .minimum_zig_version = "0.15.0", 6 + .paths = .{ 7 + "build.zig", 8 + "build.zig.zon", 9 + "src", 10 + }, 11 + }
+158
src/biquad.zig
··· 1 + //! biquad filter - the fundamental building block for digital audio effects. 2 + //! 3 + //! a biquad is a second-order IIR filter defined by 5 coefficients. 4 + //! by choosing different coefficients, you get different filter types: 5 + //! lowpass, highpass, bandpass, notch, allpass. 6 + //! 7 + //! inspired by torvalds/AudioNoise 8 + 9 + const std = @import("std"); 10 + 11 + /// filter coefficients (determines the filter's frequency response) 12 + pub const Coefficients = struct { 13 + b0: f32, 14 + b1: f32, 15 + b2: f32, 16 + a1: f32, 17 + a2: f32, 18 + }; 19 + 20 + /// filter state (remembers previous samples) 21 + pub const State = struct { 22 + w1: f32 = 0, 23 + w2: f32 = 0, 24 + }; 25 + 26 + /// process one sample through the filter (direct form II transposed) 27 + pub fn step(c: Coefficients, s: *State, x0: f32) f32 { 28 + const w0 = x0 - c.a1 * s.w1 - c.a2 * s.w2; 29 + const y0 = c.b0 * w0 + c.b1 * s.w1 + c.b2 * s.w2; 30 + s.w2 = s.w1; 31 + s.w1 = w0; 32 + return y0; 33 + } 34 + 35 + /// lowpass filter - passes frequencies below cutoff, attenuates above 36 + pub fn lowpass(freq: f32, q: f32, sample_rate: f32) Coefficients { 37 + const w0 = 2.0 * std.math.pi * freq / sample_rate; 38 + const cos_w0 = @cos(w0); 39 + const sin_w0 = @sin(w0); 40 + const alpha = sin_w0 / (2.0 * q); 41 + const a0_inv = 1.0 / (1.0 + alpha); 42 + const b1 = (1.0 - cos_w0) * a0_inv; 43 + 44 + return .{ 45 + .b0 = b1 / 2.0, 46 + .b1 = b1, 47 + .b2 = b1 / 2.0, 48 + .a1 = -2.0 * cos_w0 * a0_inv, 49 + .a2 = (1.0 - alpha) * a0_inv, 50 + }; 51 + } 52 + 53 + /// highpass filter - passes frequencies above cutoff, attenuates below 54 + pub fn highpass(freq: f32, q: f32, sample_rate: f32) Coefficients { 55 + const w0 = 2.0 * std.math.pi * freq / sample_rate; 56 + const cos_w0 = @cos(w0); 57 + const sin_w0 = @sin(w0); 58 + const alpha = sin_w0 / (2.0 * q); 59 + const a0_inv = 1.0 / (1.0 + alpha); 60 + const b1 = (1.0 + cos_w0) * a0_inv; 61 + 62 + return .{ 63 + .b0 = b1 / 2.0, 64 + .b1 = -b1, 65 + .b2 = b1 / 2.0, 66 + .a1 = -2.0 * cos_w0 * a0_inv, 67 + .a2 = (1.0 - alpha) * a0_inv, 68 + }; 69 + } 70 + 71 + /// bandpass filter - passes frequencies around center, attenuates others 72 + pub fn bandpass(freq: f32, q: f32, sample_rate: f32) Coefficients { 73 + const w0 = 2.0 * std.math.pi * freq / sample_rate; 74 + const cos_w0 = @cos(w0); 75 + const sin_w0 = @sin(w0); 76 + const alpha = sin_w0 / (2.0 * q); 77 + const a0_inv = 1.0 / (1.0 + alpha); 78 + 79 + return .{ 80 + .b0 = alpha * a0_inv, 81 + .b1 = 0, 82 + .b2 = -alpha * a0_inv, 83 + .a1 = -2.0 * cos_w0 * a0_inv, 84 + .a2 = (1.0 - alpha) * a0_inv, 85 + }; 86 + } 87 + 88 + /// notch filter - attenuates frequencies around center, passes others 89 + pub fn notch(freq: f32, q: f32, sample_rate: f32) Coefficients { 90 + const w0 = 2.0 * std.math.pi * freq / sample_rate; 91 + const cos_w0 = @cos(w0); 92 + const sin_w0 = @sin(w0); 93 + const alpha = sin_w0 / (2.0 * q); 94 + const a0_inv = 1.0 / (1.0 + alpha); 95 + 96 + return .{ 97 + .b0 = 1.0 * a0_inv, 98 + .b1 = -2.0 * cos_w0 * a0_inv, 99 + .b2 = 1.0 * a0_inv, 100 + .a1 = -2.0 * cos_w0 * a0_inv, 101 + .a2 = (1.0 - alpha) * a0_inv, 102 + }; 103 + } 104 + 105 + /// allpass filter - passes all frequencies but shifts phase (used in phasers) 106 + pub fn allpass(freq: f32, q: f32, sample_rate: f32) Coefficients { 107 + const w0 = 2.0 * std.math.pi * freq / sample_rate; 108 + const cos_w0 = @cos(w0); 109 + const sin_w0 = @sin(w0); 110 + const alpha = sin_w0 / (2.0 * q); 111 + const a0_inv = 1.0 / (1.0 + alpha); 112 + 113 + return .{ 114 + .b0 = (1.0 - alpha) * a0_inv, 115 + .b1 = -2.0 * cos_w0 * a0_inv, 116 + .b2 = 1.0, // same as a0 117 + .a1 = -2.0 * cos_w0 * a0_inv, 118 + .a2 = (1.0 - alpha) * a0_inv, 119 + }; 120 + } 121 + 122 + // tests 123 + 124 + test "lowpass attenuates high frequencies" { 125 + const lpf = lowpass(1000, 0.707, 48000); 126 + var state: State = .{}; 127 + 128 + // feed in a high frequency signal (10kHz sampled at 48kHz = ~5 samples per cycle) 129 + var max_output: f32 = 0; 130 + for (0..1000) |i| { 131 + const phase = @as(f32, @floatFromInt(i)) * 2.0 * std.math.pi * 10000.0 / 48000.0; 132 + const input = @sin(phase); 133 + const output = step(lpf, &state, input); 134 + max_output = @max(max_output, @abs(output)); 135 + } 136 + 137 + // high frequency should be heavily attenuated (well below 0.5) 138 + try std.testing.expect(max_output < 0.2); 139 + } 140 + 141 + test "lowpass passes low frequencies" { 142 + const lpf = lowpass(1000, 0.707, 48000); 143 + var state: State = .{}; 144 + 145 + // feed in a low frequency signal (100Hz) 146 + var max_output: f32 = 0; 147 + for (0..1000) |i| { 148 + const phase = @as(f32, @floatFromInt(i)) * 2.0 * std.math.pi * 100.0 / 48000.0; 149 + const input = @sin(phase); 150 + const output = step(lpf, &state, input); 151 + if (i > 100) { // skip transient 152 + max_output = @max(max_output, @abs(output)); 153 + } 154 + } 155 + 156 + // low frequency should pass through (close to 1.0) 157 + try std.testing.expect(max_output > 0.8); 158 + }
+58
src/example.zig
··· 1 + //! example: generate a filtered sine wave with tremolo 2 + //! 3 + //! writes output.wav - no ffmpeg needed. 4 + 5 + const std = @import("std"); 6 + const noise = @import("noise"); 7 + 8 + pub fn main() !void { 9 + const sample_rate: u32 = 48000; 10 + const duration: f32 = 5.0; 11 + const num_samples: u32 = @intFromFloat(@as(f32, @floatFromInt(sample_rate)) * duration); 12 + 13 + // oscillator frequency (A4 = 440 Hz) 14 + const freq: f32 = 440.0; 15 + 16 + // lowpass at 2kHz to soften the tone 17 + const lpf = noise.biquad.lowpass(2000, 0.707, @floatFromInt(sample_rate)); 18 + var lpf_state: noise.State = .{}; 19 + 20 + // tremolo at 4 Hz 21 + var tremolo = noise.Lfo.init(4.0, @floatFromInt(sample_rate)); 22 + 23 + // create output file 24 + const file = try std.fs.cwd().createFile("output.wav", .{}); 25 + defer file.close(); 26 + 27 + // write WAV header 28 + const header = noise.WavHeader.init(num_samples, sample_rate, 1); 29 + try file.writeAll(header.asBytes()); 30 + 31 + // write samples 32 + for (0..num_samples) |i| { 33 + const t: f32 = @floatFromInt(i); 34 + const sr: f32 = @floatFromInt(sample_rate); 35 + 36 + // generate sine wave 37 + const phase = t * 2.0 * std.math.pi * freq / sr; 38 + var sample = @sin(phase); 39 + 40 + // apply lowpass filter 41 + sample = noise.biquad.step(lpf, &lpf_state, sample); 42 + 43 + // apply tremolo (amplitude modulation) 44 + const mod = (tremolo.step(.sine) + 1.0) / 2.0; // 0 to 1 45 + sample *= 0.3 + 0.7 * mod; // depth of 70% 46 + 47 + // fade in/out 48 + const fade_samples: f32 = 0.1 * sr; 49 + if (t < fade_samples) { 50 + sample *= t / fade_samples; 51 + } else if (t > @as(f32, @floatFromInt(num_samples)) - fade_samples) { 52 + sample *= (@as(f32, @floatFromInt(num_samples)) - t) / fade_samples; 53 + } 54 + 55 + // write sample 56 + try file.writeAll(std.mem.asBytes(&sample)); 57 + } 58 + }
+120
src/lfo.zig
··· 1 + //! low frequency oscillator - generates control signals for modulation. 2 + //! 3 + //! uses a 32-bit phase accumulator with overflow for efficient cycling. 4 + //! the top 2 bits indicate the quarter (0-3), which determines direction 5 + //! and sign of the waveform. 6 + //! 7 + //! inspired by torvalds/AudioNoise 8 + 9 + const std = @import("std"); 10 + 11 + pub const Waveform = enum { 12 + sine, 13 + triangle, 14 + sawtooth, 15 + }; 16 + 17 + /// lfo state - call step() once per sample 18 + pub const Lfo = struct { 19 + phase: u32 = 0, 20 + step_size: u32, 21 + 22 + const two_pow_32: f64 = 4294967296.0; 23 + 24 + /// create an lfo at the given frequency 25 + pub fn init(freq: f32, sample_rate: f32) Lfo { 26 + const step_size = @as(u32, @intFromFloat(freq * two_pow_32 / sample_rate)); 27 + return .{ .step_size = step_size }; 28 + } 29 + 30 + /// set frequency (can be changed dynamically) 31 + pub fn setFreq(self: *Lfo, freq: f32, sample_rate: f32) void { 32 + self.step_size = @as(u32, @intFromFloat(freq * two_pow_32 / sample_rate)); 33 + } 34 + 35 + /// advance and return value in range [-1, 1] 36 + pub fn step(self: *Lfo, waveform: Waveform) f32 { 37 + const now = self.phase; 38 + self.phase +%= self.step_size; // wrapping add 39 + 40 + if (waveform == .sawtooth) { 41 + return uintToFraction(now) * 2.0 - 1.0; 42 + } 43 + 44 + const quarter = now >> 30; // top 2 bits 45 + var pos = now << 2; // remaining 30 bits scaled to full range 46 + 47 + // quarters 1 and 3 reverse direction 48 + if (quarter & 1 != 0) { 49 + pos = ~pos; 50 + } 51 + 52 + var val: f32 = undefined; 53 + if (waveform == .sine) { 54 + // approximate sine using parabola 55 + val = uintToFraction(pos); 56 + val = 4.0 * val * (1.0 - val); // parabolic approximation 57 + } else { 58 + // triangle 59 + val = uintToFraction(pos); 60 + } 61 + 62 + // quarters 2 and 3 are negative 63 + if (quarter & 2 != 0) { 64 + val = -val; 65 + } 66 + 67 + return val; 68 + } 69 + 70 + fn uintToFraction(x: u32) f32 { 71 + return @as(f32, @floatFromInt(x)) / @as(f32, two_pow_32); 72 + } 73 + }; 74 + 75 + // tests 76 + 77 + test "lfo sine stays in range" { 78 + var lfo = Lfo.init(1.0, 48000); 79 + 80 + for (0..96000) |_| { 81 + const val = lfo.step(.sine); 82 + try std.testing.expect(val >= -1.0 and val <= 1.0); 83 + } 84 + } 85 + 86 + test "lfo triangle stays in range" { 87 + var lfo = Lfo.init(1.0, 48000); 88 + 89 + for (0..96000) |_| { 90 + const val = lfo.step(.triangle); 91 + try std.testing.expect(val >= -1.0 and val <= 1.0); 92 + } 93 + } 94 + 95 + test "lfo sawtooth stays in range" { 96 + var lfo = Lfo.init(1.0, 48000); 97 + 98 + for (0..96000) |_| { 99 + const val = lfo.step(.sawtooth); 100 + try std.testing.expect(val >= -1.0 and val <= 1.0); 101 + } 102 + } 103 + 104 + test "lfo completes one cycle" { 105 + var lfo = Lfo.init(1.0, 1000); // 1Hz at 1000 samples/sec = 1000 samples per cycle 106 + 107 + var crossed_zero_count: u32 = 0; 108 + var prev: f32 = 0; 109 + 110 + for (0..1000) |_| { 111 + const val = lfo.step(.sine); 112 + if (prev < 0 and val >= 0) { 113 + crossed_zero_count += 1; 114 + } 115 + prev = val; 116 + } 117 + 118 + // should cross zero rising once per cycle 119 + try std.testing.expect(crossed_zero_count == 1); 120 + }
+15
src/root.zig
··· 1 + //! noise - sample-by-sample digital audio processing 2 + //! 3 + //! a learning-focused dsp library inspired by torvalds/AudioNoise. 4 + //! no FFT, no block processing - just IIR filters and delay lines. 5 + 6 + pub const biquad = @import("biquad.zig"); 7 + pub const lfo = @import("lfo.zig"); 8 + pub const wav = @import("wav.zig"); 9 + 10 + // re-export common types 11 + pub const Coefficients = biquad.Coefficients; 12 + pub const State = biquad.State; 13 + pub const Lfo = lfo.Lfo; 14 + pub const Waveform = lfo.Waveform; 15 + pub const WavHeader = wav.Header;
+52
src/wav.zig
··· 1 + //! wav file writing - just a header and raw samples. 2 + //! 3 + //! WAV is RIFF format: 44 bytes of header, then PCM data. 4 + 5 + const std = @import("std"); 6 + 7 + pub const Header = extern struct { 8 + riff: [4]u8 = "RIFF".*, 9 + file_size: u32, // file size - 8 10 + wave: [4]u8 = "WAVE".*, 11 + fmt: [4]u8 = "fmt ".*, 12 + fmt_size: u32 = 16, // PCM format chunk size 13 + audio_format: u16 = 3, // 3 = IEEE float 14 + channels: u16, 15 + sample_rate: u32, 16 + byte_rate: u32, // sample_rate * channels * bytes_per_sample 17 + block_align: u16, // channels * bytes_per_sample 18 + bits_per_sample: u16 = 32, 19 + data: [4]u8 = "data".*, 20 + data_size: u32, // num_samples * channels * bytes_per_sample 21 + 22 + pub fn init(num_samples: u32, sample_rate: u32, channels: u16) Header { 23 + const bytes_per_sample: u32 = 4; // f32 24 + const data_size = num_samples * channels * bytes_per_sample; 25 + return .{ 26 + .file_size = 36 + data_size, 27 + .channels = channels, 28 + .sample_rate = sample_rate, 29 + .byte_rate = sample_rate * channels * bytes_per_sample, 30 + .block_align = @intCast(channels * bytes_per_sample), 31 + .data_size = data_size, 32 + }; 33 + } 34 + 35 + pub fn asBytes(self: *const Header) []const u8 { 36 + return std.mem.asBytes(self); 37 + } 38 + }; 39 + 40 + comptime { 41 + if (@sizeOf(Header) != 44) @compileError("WAV header must be 44 bytes"); 42 + } 43 + 44 + test "header size" { 45 + try std.testing.expectEqual(44, @sizeOf(Header)); 46 + } 47 + 48 + test "header values" { 49 + const h = Header.init(48000, 48000, 1); // 1 second mono 50 + try std.testing.expectEqual(@as(u32, 192000), h.data_size); // 48000 * 1 * 4 51 + try std.testing.expectEqual(@as(u32, 192036), h.file_size); // 36 + data_size 52 + }