Something went wrong. Try again.
Play and record audio on Windows from Zig through WASAPI and COM, without linking a C library.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Play a tone and record it back, then check that what came out is what went in.//!//! This is the one test that exercises the whole library at once: format negotiation,//! the render thread, the queue, the sample conversion, WASAPI itself, the capture//! thread, and the conversion back. Everything else in the suite tests a piece.//!//! It needs real audio hardware, so it reports `SkipZigTest` where there is none --//! which includes every hosted continuous-integration runner. That makes it a test for//! a developer's machine and for a self-hosted runner, and the README says so rather//! than pretending the automated suite covers this.//!//! ## What it asserts, and why that and not a sample comparison//!//! Not that the recording matches the tone sample for sample -- it cannot. The audio//! engine resamples, the endpoint may mix in another format, the recording starts at an//! arbitrary point in the tone's phase, and both ends have latency measured in periods.//! Comparing waveforms would fail for a dozen reasons that are all correct behaviour.//!//! What it asserts is that the *energy is at the right frequency*: a Goertzel filter at//! the tone's pitch against the same filter at neighbouring pitches. That survives//! every one of those legitimate differences and still catches the things that matter --//! a channel swap, a conversion that scales wrongly, a ring that repeats or drops//! blocks, a permutation that silences a channel, or audio that never arrives at all.
const std = @import("std");const wasapi = @import("wasapi");
const frequency = 440.0;const amplitude = 0.25;/// Long enough for the filter to resolve the tone well clear of its neighbours, short/// enough that the test is not tedious.const seconds = 1.0;
test "a tone played through a Stream comes back through a loopback Capture" { var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); const gpa = std.testing.allocator;
// The capture opens first, so that it is running before there is anything to record // -- otherwise the start of the tone is missed and the measurement is of less // audio than intended. const capture = wasapi.Capture.open(gpa, io, .{ .name = "zig-wsapi loopback test", .source = .loopback, }) catch |err| switch (err) { // No sound card, or none that can be opened: not a failure of this library. error.DeviceNotFound, error.DeviceInUse, error.AccessDenied => return error.SkipZigTest, else => |e| return e, }; defer capture.close(io);
if (!try capture.waitStreaming(io, seconds_timeout(5))) return error.SkipZigTest;
const rate = capture.rate(); const channels = capture.channels(); try std.testing.expect(rate > 0); try std.testing.expect(channels > 0);
const stream = wasapi.Stream.open(gpa, io, .{ .name = "zig-wsapi loopback test tone", .channels = 2, .rate = rate, }) catch |err| switch (err) { error.DeviceNotFound, error.DeviceInUse, error.AccessDenied => return error.SkipZigTest, else => |e| return e, }; defer stream.close(io);
if (!try stream.waitStreaming(io, seconds_timeout(5))) return error.SkipZigTest;
// Feed the tone from another thread, so this one can read the recording as it // arrives rather than filling the queue and then draining it. var player: Player = .{ .stream = stream, .io = io, .rate = rate }; const thread = try std.Thread.spawn(.{}, Player.run, .{&player}); defer thread.join();
const frames_wanted: usize = @intFromFloat(seconds * @as(f64, @floatFromInt(rate))); const recorded = try gpa.alloc(f32, frames_wanted * channels); defer gpa.free(recorded);
var got: usize = 0; while (got < frames_wanted) { const frames = try capture.readAll( io, recorded[got * channels ..], seconds_timeout(5), ); if (frames == 0) break; got += frames; }
player.stop.store(true, .release);
// Anything much short of what was asked for means audio was not arriving, which is // a real failure rather than something to skip. try std.testing.expect(got > frames_wanted / 2);
// Channel zero is enough: the tone is the same in both, and a swap would not change // that. What a swap *would* change is tested separately in `channel.zig`. const tone = goertzel(recorded[0 .. got * channels], channels, 0, frequency, rate);
// The filter at pitches far enough away that the tone's own skirt does not reach // them. If the recording were noise, or the wrong audio, or scaled wrongly, these // would not be far below the tone. const below = goertzel(recorded[0 .. got * channels], channels, 0, frequency / 2, rate); const above = goertzel(recorded[0 .. got * channels], channels, 0, frequency * 2, rate); const far = goertzel(recorded[0 .. got * channels], channels, 0, 3000, rate);
// Twenty times is about 26 dB, which is comfortably clear of anything a legitimate // resampler or a dithering endpoint would leave behind, and nowhere near tight // enough to fail on a machine with other audio playing quietly. try std.testing.expect(tone > below * 20); try std.testing.expect(tone > above * 20); try std.testing.expect(tone > far * 20);
// And the level is roughly what was played, which catches a conversion that is off // by a factor rather than merely off in shape. Generous bounds: the endpoint's own // volume is somewhere in this path and this test does not touch it. try std.testing.expect(tone > amplitude / 20.0);
// Nothing should have been dropped in either direction over a second of audio on an // otherwise idle machine. try std.testing.expectEqual(@as(u64, 0), capture.overruns());}
/// Writes a sine into a stream until told to stop.const Player = struct { stream: *wasapi.Stream, io: std.Io, rate: u32, stop: std.atomic.Value(bool) = .init(false),
fn run(self: *Player) void { var phase: f32 = 0; const step = 2.0 * std.math.pi * frequency / @as(f32, @floatFromInt(self.rate)); var block: [960 * 2]f32 = undefined;
while (!self.stop.load(.acquire)) { for (0..block.len / 2) |i| { const sample = @sin(phase) * amplitude; block[i * 2 + 0] = sample; block[i * 2 + 1] = sample; phase += step; if (phase > 2.0 * std.math.pi) phase -= 2.0 * std.math.pi; } self.stream.writeAll(self.io, &block, seconds_timeout(5)) catch return; } }};
/// The magnitude of one frequency in one channel of interleaved audio.////// A Goertzel filter: a single-bin discrete Fourier transform, which is the cheapest/// honest way to ask "how much of this pitch is in here" without a whole transform.fn goertzel( interleaved: []const f32, channels: u16, which: u16, hz: f64, rate: u32,) f64 { const w = 2.0 * std.math.pi * hz / @as(f64, @floatFromInt(rate)); const coeff = 2.0 * @cos(w);
var s1: f64 = 0; var s2: f64 = 0; var count: usize = 0;
var i: usize = which; while (i < interleaved.len) : (i += channels) { const s = @as(f64, interleaved[i]) + coeff * s1 - s2; s2 = s1; s1 = s; count += 1; } if (count == 0) return 0;
const power = s1 * s1 + s2 * s2 - coeff * s1 * s2; // Scaled by the sample count so the answer is an amplitude rather than something // that grows with the length of the recording. return @sqrt(@max(0, power)) / @as(f64, @floatFromInt(count)) * 2;}
fn seconds_timeout(s: i64) std.Io.Timeout { return .{ .duration = .{ .raw = .fromSeconds(s), .clock = .awake } };}
test "the filter finds a synthetic tone and rejects its neighbours" { // The measurement itself, checked against audio this test made rather than against // audio a sound card made -- so a failure here is the filter's fault and a failure // above is the library's. Runs on any host, with no hardware. const rate = 48000; const n = rate; // one second var samples: [n * 2]f32 = undefined;
var phase: f64 = 0; const step = 2.0 * std.math.pi * frequency / @as(f64, rate); for (0..n) |i| { const sample: f32 = @floatCast(@sin(phase) * amplitude); samples[i * 2 + 0] = sample; samples[i * 2 + 1] = 0; // silent right channel, to prove the stride works phase += step; }
const at_tone = goertzel(&samples, 2, 0, frequency, rate); const at_half = goertzel(&samples, 2, 0, frequency / 2, rate); const at_double = goertzel(&samples, 2, 0, frequency * 2, rate);
// The amplitude comes back, to within the filter's own leakage. try std.testing.expectApproxEqAbs(@as(f64, amplitude), at_tone, 0.02); try std.testing.expect(at_tone > at_half * 20); try std.testing.expect(at_tone > at_double * 20);
// And the channel stride is respected: the right channel is silent. try std.testing.expect(goertzel(&samples, 2, 1, frequency, rate) < 0.001);}