audio: redesign audio module

Signed-off-by: Stephen Gutekanst <stephen@hexops.com>
This commit is contained in:
Stephen Gutekanst 2024-04-16 19:14:37 -07:00
parent 2b7b8f5571
commit 282c83877e
4 changed files with 185 additions and 90 deletions

View file

@ -665,7 +665,7 @@ fn buildExamples(
has_assets: bool = false, has_assets: bool = false,
use_module_api: bool = false, use_module_api: bool = false,
}{ }{
.{ .name = "sysaudio", .deps = &.{} }, .{ .name = "sysaudio", .deps = &.{}, .use_module_api = true },
.{ .{
.name = "gkurve", .name = "gkurve",
.deps = &.{ .zigimg, .freetype, .assets }, .deps = &.{ .zigimg, .freetype, .assets },

View file

@ -25,6 +25,7 @@ pub const Mod = mach.Mod(@This());
pub const global_events = .{ pub const global_events = .{
.init = .{ .handler = init }, .init = .{ .handler = init },
.tick = .{ .handler = tick }, .tick = .{ .handler = tick },
.audio_state_change = .{ .handler = audioStateChange },
}; };
pub const local_events = .{ pub const local_events = .{
@ -32,6 +33,10 @@ pub const local_events = .{
.tick = .{ .handler = tick }, .tick = .{ .handler = tick },
}; };
pub const components = .{
.play_after = .{ .type = f32 },
};
fn init(audio: *mach.Audio.Mod, piano: *Mod) void { fn init(audio: *mach.Audio.Mod, piano: *Mod) void {
// Initialize audio module // Initialize audio module
audio.send(.init, .{}); audio.send(.init, .{});
@ -40,9 +45,29 @@ fn init(audio: *mach.Audio.Mod, piano: *Mod) void {
piano.init(.{}); piano.init(.{});
} }
fn tick( fn audioStateChange(
engine: *mach.Engine.Mod,
audio: *mach.Audio.Mod, audio: *mach.Audio.Mod,
which: mach.EntityID,
piano: *Mod,
) !void {
if (audio.get(which, .playing) == false) {
if (piano.get(which, .play_after)) |frequency| {
// Play a new sound
const entity = try audio.newEntity();
try audio.set(entity, .samples, try fillTone(audio, frequency));
try audio.set(entity, .playing, true);
try audio.set(entity, .index, 0);
}
// Remove the entity for the old sound
try audio.removeEntity(which);
}
}
fn tick(
core: *mach.Core.Mod,
audio: *mach.Audio.Mod,
piano: *Mod,
) !void { ) !void {
var iter = mach.core.pollEvents(); var iter = mach.core.pollEvents();
while (iter.next()) |event| { while (iter.next()) |event| {
@ -56,27 +81,28 @@ fn tick(
else => { else => {
// Play a new sound // Play a new sound
const entity = try audio.newEntity(); const entity = try audio.newEntity();
try audio.set(entity, .samples, try fillTone(audio, ev.key)); try audio.set(entity, .samples, try fillTone(audio, keyToFrequency(ev.key)));
try audio.set(entity, .playing, true); try audio.set(entity, .playing, true);
try audio.set(entity, .index, 0); try audio.set(entity, .index, 0);
// After that sound plays, we'll chain on another sound that is one semi-tone higher.
const one_semi_tone_higher = keyToFrequency(ev.key) * math.pow(f32, 2.0, (1.0 / 12.0));
try piano.set(entity, .play_after, one_semi_tone_higher);
}, },
} }
}, },
.close => engine.send(.exit, .{}), .close => core.send(.exit, .{}),
else => {}, else => {},
} }
} }
audio.send(.render, .{});
const back_buffer_view = mach.core.swap_chain.getCurrentTextureView().?; const back_buffer_view = mach.core.swap_chain.getCurrentTextureView().?;
mach.core.swap_chain.present(); mach.core.swap_chain.present();
back_buffer_view.release(); back_buffer_view.release();
} }
fn fillTone(audio: *mach.Audio.Mod, key: mach.core.Key) ![]const f32 { fn fillTone(audio: *mach.Audio.Mod, frequency: f32) ![]const f32 {
const frequency = keyToFrequency(key);
const channels = audio.state().player.channels().len; const channels = audio.state().player.channels().len;
const sample_rate: f32 = @floatFromInt(audio.state().player.sampleRate()); const sample_rate: f32 = @floatFromInt(audio.state().player.sampleRate());
const duration: f32 = 1.5 * @as(f32, @floatFromInt(channels)) * sample_rate; // play the tone for 1.5s const duration: f32 = 1.5 * @as(f32, @floatFromInt(channels)) * sample_rate; // play the tone for 1.5s

View file

@ -1,13 +1,17 @@
const mach = @import("mach"); const mach = @import("mach");
const Piano = @import("Piano.zig"); // The global list of Mach modules registered for use in our application.
// The list of modules to be used in our application.
// Our Piano itself is implemented in our own module called Piano.
pub const modules = .{ pub const modules = .{
mach.Engine, mach.Core,
mach.Audio, mach.Audio,
Piano, @import("Piano.zig"),
}; };
pub const App = mach.App; // TODO(important): use standard entrypoint instead
pub fn main() !void {
// Initialize mach.Core
try mach.core.initModule();
// Main loop
while (try mach.core.tick()) {}
}

View file

@ -14,25 +14,36 @@ pub const components = .{
pub const local_events = .{ pub const local_events = .{
.init = .{ .handler = init }, .init = .{ .handler = init },
.render = .{ .handler = render }, .audio_tick = .{ .handler = audioTick },
}; };
pub const global_events = .{
.deinit = .{ .handler = deinit },
.audio_state_change = .{ .handler = fn (mach.EntityID) void },
};
const log = std.log.scoped(name);
// The number of milliseconds worth of audio to render ahead of time. The lower this number is, the
// less latency there is in playing new audio. The higher this number is, the less chance there is
// of glitchy audio playback.
//
// By default, we use three times 1/60th of a second - i.e. 3 frames could drop before audio would
// stop playing smoothly assuming a 60hz application render rate.
ms_render_ahead: f32 = 16,
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
ctx: sysaudio.Context, ctx: sysaudio.Context,
player: sysaudio.Player, player: sysaudio.Player,
mixing_buffer: []f32, output_mu: std.Thread.Mutex = .{},
buffer: SampleBuffer = SampleBuffer.init(), output: SampleBuffer,
mutex: std.Thread.Mutex = .{}, mixing_buffer: ?std.ArrayListUnmanaged(f32) = null,
cond: std.Thread.Condition = .{}, render_num_samples: usize = undefined,
debug: bool = false,
var gpa = std.heap.GeneralPurposeAllocator(.{}){}; var gpa = std.heap.GeneralPurposeAllocator(.{}){};
// Enough space to hold 30ms of audio @ 48000hz, f32 audio samples, 6 channels const SampleBuffer = std.fifo.LinearFifo(u8, .Dynamic);
//
// This buffer is only used to transfer samples from the .render event handler to the audio thread,
// so it being larger than needed introduces no latency but it being smaller than needed could block
// the .render event handler.
pub const SampleBuffer = std.fifo.LinearFifo(f32, .{ .Static = 48000 * 0.03 * @sizeOf(f32) * 6 });
fn init(audio: *Mod) !void { fn init(audio: *Mod) !void {
const allocator = gpa.allocator(); const allocator = gpa.allocator();
@ -42,27 +53,26 @@ fn init(audio: *Mod) !void {
// TODO(audio): let people handle these errors // TODO(audio): let people handle these errors
// TODO(audio): enable selecting non-default devices // TODO(audio): enable selecting non-default devices
const device = ctx.defaultDevice(.playback) orelse return error.NoDeviceFound; const device = ctx.defaultDevice(.playback) orelse return error.NoDeviceFound;
// TODO(audio): allow us to set user_data after creation of the player, so that we do not need var player = try ctx.createPlayer(device, writeFn, .{ .user_data = audio });
// __state access.
var player = try ctx.createPlayer(device, writeFn, .{ .user_data = &audio.__state }); const debug_str = std.process.getEnvVarOwned(
allocator,
const frame_size = @sizeOf(f32) * player.channels().len; // size of an audio frame "MACH_DEBUG_AUDIO",
const sample_rate = player.sampleRate(); // number of samples per second ) catch |err| switch (err) {
const sample_rate_ms = sample_rate / 1000; // number of samples per ms error.EnvironmentVariableNotFound => null,
else => return err,
// A 30ms buffer of audio that we will use to store mixed samples before sending them to the };
// audio thread for playback. const debug = if (debug_str) |s| blk: {
// defer allocator.free(s);
// TODO(audio): enable audio rendering loop to run at different frequency to reduce this buffer break :blk std.ascii.eqlIgnoreCase(s, "true");
// size and reduce latency. } else false;
const mixing_buffer = try allocator.alloc(f32, 30 * sample_rate_ms * frame_size);
audio.init(.{ audio.init(.{
.allocator = allocator, .allocator = allocator,
.ctx = ctx, .ctx = ctx,
.player = player, .player = player,
.mixing_buffer = mixing_buffer, .output = SampleBuffer.init(allocator),
.debug = debug,
}); });
try player.start(); try player.start();
@ -71,24 +81,59 @@ fn init(audio: *Mod) !void {
fn deinit(audio: *Mod) void { fn deinit(audio: *Mod) void {
audio.state().player.deinit(); audio.state().player.deinit();
audio.state().ctx.deinit(); audio.state().ctx.deinit();
audio.state().allocator.free(audio.state().mixing_buffer); if (audio.state().mixing_buffer) |*b| b.deinit(audio.state().allocator);
var archetypes_iter = audio.entities.query(.{ .all = &.{
.{ .mach_audio = &.{.samples} },
} });
while (archetypes_iter.next()) |archetype| {
const samples = archetype.slice(.mach_audio, .samples);
for (samples) |buf| buf.deinit(audio.state().allocator);
}
} }
fn render(audio: *Mod) !void { /// .audio_tick is sent whenever the audio driver requests more audio samples to output to the
// Prepare the next buffer of mixed audio by querying entities and mixing the samples they want /// speakers. Usually the driver is requesting a small amount of samples, e.g. ~4096 samples.
// to play. ///
var mixing_buffer = audio.state().mixing_buffer; /// The audio driver asks for more samples on a different, often high-priority OS thread. It does
@memset(mixing_buffer, 0); /// not block waiting for .audio_tick to be dispatched, instead it simply returns whatever samples
var max_samples: usize = 0; /// are already prepared in the audio.state().output buffer ahead of time. This ensures that even
/// if the system is under heavy load, or a few frames are particularly slow, that audio
/// (hopefully) continues playing uninterrupted.
///
/// The goal of this event handler, then, is to prepare enough audio samples ahead of time in the
/// audio.state().output buffer that feed the driver so it does not get hungry and play silence
/// instead. At the same time, we don't want to play too far ahead as that would cause latency
/// between e.g. user interactions and audio actually playing - so in practice the amount we play
/// ahead is rather small and imperceivable to most humans.
fn audioTick(audio: *Mod) !void {
const allocator = audio.state().allocator;
var player = audio.state().player;
// How many samples the driver last expected us to produce.
const driver_expects = audio.state().render_num_samples;
// How many audio samples we will render ahead by
const samples_per_ms = @as(f32, @floatFromInt(player.sampleRate())) / 1000.0;
const render_ahead: u32 = @as(u32, @intFromFloat(@trunc(audio.state().ms_render_ahead * samples_per_ms))) * @as(u32, @truncate(player.channels().len));
// Our goal is to ensure that we always have pre-rendered the number of samples the driver last
// expected, expects, plus the play ahead amount.
const goal_pre_rendered = driver_expects + render_ahead;
audio.state().output_mu.lock();
const already_prepared = audio.state().output.readableLength() / player.format().size();
const render_num_samples = if (already_prepared > goal_pre_rendered) 0 else goal_pre_rendered - already_prepared;
audio.state().output_mu.unlock();
if (render_num_samples < 0) return; // we do not need to render more audio right now
// Ensure our f32 mixing buffer has enough space for the samples we will render right now.
// This will allocate to grow but never shrink.
var mixing_buffer = if (audio.state().mixing_buffer) |b| b else blk: {
const b = try std.ArrayListUnmanaged(f32).initCapacity(allocator, render_num_samples);
audio.state().mixing_buffer = b;
break :blk b;
};
try mixing_buffer.resize(allocator, render_num_samples); // grows, but never shrinks
// Zero the mixing buffer to silence: if no audio is mixed in below, then we want silence
// not undefined memory.
@memset(mixing_buffer.items, 0);
var max_samples: usize = 0;
var archetypes_iter = audio.entities.query(.{ .all = &.{ var archetypes_iter = audio.entities.query(.{ .all = &.{
.{ .mach_audio = &.{ .samples, .playing, .index } }, .{ .mach_audio = &.{ .samples, .playing, .index } },
} }); } });
@ -101,11 +146,12 @@ fn render(audio: *Mod) !void {
) |id, samples, playing, index| { ) |id, samples, playing, index| {
if (!playing) continue; if (!playing) continue;
const to_read = @min(samples.len - index, mixing_buffer.len); const to_read = @min(samples.len - index, mixing_buffer.items.len);
mixSamples(mixing_buffer[0..to_read], samples[index..][0..to_read]); mixSamples(mixing_buffer.items[0..to_read], samples[index..][0..to_read]);
max_samples = @max(max_samples, to_read); max_samples = @max(max_samples, to_read);
if (index + to_read >= samples.len) { if (index + to_read >= samples.len) {
// No longer playing, we've read all samples // No longer playing, we've read all samples
audio.sendGlobal(.audio_state_change, .{id});
try audio.set(id, .playing, false); try audio.set(id, .playing, false);
try audio.set(id, .index, 0); try audio.set(id, .index, 0);
continue; continue;
@ -114,44 +160,63 @@ fn render(audio: *Mod) !void {
} }
} }
// Write our mixed buffer to the audio thread via the sample buffer. // Write our rendered samples to the fifo, expanding its size as needed and converting our f32
audio.state().mutex.lock(); // samples to the format the driver expects.
defer audio.state().mutex.unlock(); // TODO(audio): handle potential OOM here
while (audio.state().buffer.writableLength() < max_samples) { audio.state().output_mu.lock();
audio.state().cond.wait(&audio.state().mutex); defer audio.state().output_mu.unlock();
} const out_buffer_len = render_num_samples * player.format().size();
audio.state().buffer.writeAssumeCapacity(mixing_buffer[0..max_samples]); const out_buffer = try audio.state().output.writableWithSize(out_buffer_len);
} std.debug.assert(mixing_buffer.items.len == render_num_samples);
// Callback invoked on the audio thread.
fn writeFn(audio_opaque: ?*anyopaque, output: []u8) void {
const audio: *@This() = @ptrCast(@alignCast(audio_opaque));
// Clear buffer from previous samples
@memset(output, 0);
const total_samples = @divExact(output.len, audio.player.format().size());
var i: usize = 0;
while (i < total_samples) {
audio.mutex.lock();
defer audio.mutex.unlock();
const read_slice = audio.buffer.readableSlice(0);
const read_len = @min(read_slice.len, total_samples - i);
if (read_len == 0) return;
sysaudio.convertTo( sysaudio.convertTo(
f32, f32,
read_slice[0..read_len], mixing_buffer.items,
audio.player.format(), player.format(),
output[i * @sizeOf(f32) ..][0 .. read_len * @sizeOf(f32)], out_buffer[0..out_buffer_len], // writableWithSize may return a larger slice than needed
); );
audio.state().output.update(out_buffer_len);
}
i += read_len; // Callback invoked on the audio thread
audio.buffer.discard(read_len); fn writeFn(audio_opaque: ?*anyopaque, output: []u8) void {
audio.cond.signal(); const audio: *Mod = @ptrCast(@alignCast(audio_opaque));
// Notify that we are writing audio frames now
//
// Note that we do not *wait* at all for .audio_tick to complete, this is an asynchronous
// dispatch of the event. The expectation is that audio.state().output already has enough
// samples in it that we can return right now. The event is just a signal dispatched on another
// thread to enable reacting to audio events in realtime.
const format_size = audio.state().player.format().size();
const render_num_samples = @divExact(output.len, format_size);
audio.state().render_num_samples = render_num_samples;
audio.send(.audio_tick, .{});
// Read the prepared audio samples and directly @memcpy them to the output buffer.
audio.state().output_mu.lock();
defer audio.state().output_mu.unlock();
var read_slice = audio.state().output.readableSlice(0);
if (read_slice.len < output.len) {
// We do not have enough audio data prepared. Busy-wait until we do, otherwise the audio
// thread may become de-sync'd with the loop responsible for producing it.
audio.send(.audio_tick, .{});
if (audio.state().debug) log.debug("resync, found {} samples but need {} (nano timestamp {})", .{ read_slice.len / format_size, output.len / format_size, std.time.nanoTimestamp() });
audio.state().output_mu.unlock();
l: while (true) {
audio.state().output_mu.lock();
if (audio.state().output.readableLength() >= output.len) {
read_slice = audio.state().output.readableSlice(0);
break :l;
} }
audio.state().output_mu.unlock();
}
}
if (read_slice.len > output.len) {
read_slice = read_slice[0..output.len];
}
@memcpy(output[0..read_slice.len], read_slice);
audio.state().output.discard(read_slice.len);
} }
// TODO(audio): remove this switch, currently ReleaseFast/ReleaseSmall have some weird behavior if // TODO(audio): remove this switch, currently ReleaseFast/ReleaseSmall have some weird behavior if