// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! An audio file decoded once into memory, ready to be played any number of //! times. //! //! A Sound belongs to whoever loaded it, not to any Player, so it can be loaded //! -- and a bad file refused -- before there is a connection to the sound //! output at all. A Player that has played it holds a stream for it until the //! Sound is released with `Player.release`, which has to happen before `deinit`. const std = @import("std"); const Allocator = std.mem.Allocator; const decode = @import("decode.zig"); const Sound = @This(); /// Interleaved, `channels` to a frame, nominally within -1 to 1. samples: []f32, /// Frames a second, as the file was recorded. The system resamples to the /// output device. rate: u32, /// As the file has them, in the WAVE order: front left, front right, centre, /// LFE, back left, back right, then the sides. channels: u16, /// How many Players hold a stream for this Sound. Counted only where safety /// checks are on, which is where `deinit` asserts it has reached zero. holders: if (std.debug.runtime_safety) u32 else void = if (std.debug.runtime_safety) 0 else {}, pub const Options = struct { /// The longest Sound that will be loaded, in milliseconds; anything that /// decodes to more is refused with `error.SoundTooLong` rather than held /// in memory. Thirty seconds is far more than an alert needs, and an hour /// of stereo at 48 kHz would be over a gigabyte. max_duration_ms: u32 = 30_000, /// The largest file `load` will read, for the same reason. max_file_bytes: usize = 64 << 20, }; pub const Format = decode.Format; pub const DecodeError = decode.Error; pub const LoadError = std.Io.Dir.ReadFileAllocError || DecodeError; /// Reads and decodes the file at `path`, relative to `dir`. pub fn load(gpa: Allocator, io: std.Io, dir: std.Io.Dir, path: []const u8, options: Options) LoadError!Sound { const bytes = dir.readFileAlloc(io, path, gpa, .limited(options.max_file_bytes)) catch |err| switch (err) { error.StreamTooLong => return error.SoundTooLong, else => |e| return e, }; defer gpa.free(bytes); return fromBytes(gpa, bytes, options); } /// Decodes a file already in memory, such as one built in with `@embedFile`. /// `bytes` is not kept. pub fn fromBytes(gpa: Allocator, bytes: []const u8, options: Options) DecodeError!Sound { const decoded = try decode.decode(gpa, bytes, options.max_duration_ms); return .{ .samples = decoded.samples, .rate = decoded.rate, .channels = decoded.channels }; } pub fn deinit(sound: *Sound, gpa: Allocator) void { if (std.debug.runtime_safety) std.debug.assert(sound.holders == 0); // release it from every Player first gpa.free(sound.samples); sound.* = undefined; } /// Samples per channel. pub fn frames(sound: Sound) usize { return sound.samples.len / sound.channels; } /// How long the Sound plays for, in milliseconds. pub fn durationMs(sound: Sound) u64 { return @as(u64, sound.frames()) * std.time.ms_per_s / sound.rate; } const testing = std.testing; test "fromBytes keeps nothing of the input" { const bytes = try testing.allocator.dupe(u8, @embedFile("tests/data/tone-s16-mono-44100.wav")); var sound = try fromBytes(testing.allocator, bytes, .{}); testing.allocator.free(bytes); defer sound.deinit(testing.allocator); try testing.expectEqual(@as(usize, 4410), sound.frames()); try testing.expectEqual(@as(u64, 100), sound.durationMs()); } test "a Sound longer than the limit is refused" { try testing.expectError(error.SoundTooLong, fromBytes( testing.allocator, @embedFile("tests/data/tone-s16-mono-44100.wav"), .{ .max_duration_ms = 50 }, )); } test "load reads from a directory" { var tmp = testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(testing.io, .{ .sub_path = "bell.flac", .data = @embedFile("tests/data/tone-stereo-44100.flac") }); var sound = try load(testing.allocator, testing.io, tmp.dir, "bell.flac", .{}); defer sound.deinit(testing.allocator); try testing.expectEqual(@as(u16, 2), sound.channels); try testing.expectError(error.SoundTooLong, load(testing.allocator, testing.io, tmp.dir, "bell.flac", .{ .max_file_bytes = 100 })); try testing.expectError(error.FileNotFound, load(testing.allocator, testing.io, tmp.dir, "missing.wav", .{})); }