-- SPDX-FileCopyrightText: © 2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. -- Exercises the sound module without touching audio hardware: the C float32 -- sample buffer (mix/osc/fade/get/set), PCM decode and resample, the WAV -- loader, the synth tone generator, and the software mixer. Device playback -- (which needs /dev/snd) is covered by the manual smoke test, not here. local testimony = require("testimony") local core = require("sound.core") local sound = require("sound") local wav = require("sound.wav") local synth = require("sound.synth") local A = testimony -- approximate float equality local function near(expected, actual, eps, msg) eps = eps or 1e-4 A.assert_true( math.abs(expected - actual) <= eps, string.format("%s (expected ~%.6f, got %.6f)", msg or "near", expected, actual) ) end -- little-endian s16 PCM string from a list of integer samples local function s16(...) local parts = {} for _, v in ipairs({ ... }) do if v < 0 then v = v + 65536 end parts[#parts + 1] = string.char(v % 256, math.floor(v / 256) % 256) end return table.concat(parts) end -- ── buffer basics ────────────────────────────────────────────────────── local t = testimony.new("== sound ==") t:that("buffer: geometry and zero init", function() local b = core.buffer(100, 2) A.assert_equal(100, b:frames()) A.assert_equal(2, b:channels()) near(0.0, b:get(1, 1)) near(0.0, b:get(100, 2)) end) t:that("buffer: channels default to 2", function() A.assert_equal(2, core.buffer(10):channels()) end) t:that("buffer: set/get round-trips, 1-indexed", function() local b = core.buffer(4, 2) b:set(1, 1, 0.5) b:set(4, 2, -0.25) near(0.5, b:get(1, 1)) near(-0.25, b:get(4, 2)) A.assert_nil(b:get(0, 1)) -- out of range A.assert_nil(b:get(5, 1)) A.assert_nil(b:get(1, 3)) end) t:that("buffer: clear resets to silence", function() local b = core.buffer(4, 1) b:set(2, 1, 0.9) b:clear() near(0.0, b:get(2, 1)) end) t:that("buffer: tostring length == frames*channels*4", function() A.assert_equal(8 * 2 * 4, #core.buffer(8, 2):tostring()) end) -- ── mixing ───────────────────────────────────────────────────────────── t:that("mix: additive accumulation", function() local dst = core.buffer(4, 1) local src = core.buffer(4, 1) for i = 1, 4 do src:set(i, 1, 0.1) end dst:mix(src, 1, 1, 4) -- unity gain dst:mix(src, 1, 1, 4) -- mix again -> 0.2 near(0.2, dst:get(3, 1)) end) t:that("mix: scalar gain applies to all channels", function() local dst = core.buffer(2, 2) local src = core.buffer(2, 2) src:set(1, 1, 1.0) src:set(1, 2, 1.0) dst:mix(src, 1, 1, 2, 0.5) -- one gain -> master volume near(0.5, dst:get(1, 1)) near(0.5, dst:get(1, 2)) end) t:that("mix: per-channel gains (pan)", function() local dst = core.buffer(1, 2) local src = core.buffer(1, 2) src:set(1, 1, 1.0) src:set(1, 2, 1.0) dst:mix(src, 1, 1, 1, 0.25, 0.75) near(0.25, dst:get(1, 1)) near(0.75, dst:get(1, 2)) end) t:that("mix: mono source upmixes into stereo destination", function() local dst = core.buffer(3, 2) local src = core.buffer(3, 1) for i = 1, 3 do src:set(i, 1, 0.4) end dst:mix(src, 1, 1, 3, 1.0, 0.5) near(0.4, dst:get(2, 1)) near(0.2, dst:get(2, 2)) end) t:that("mix: offsets are 1-indexed, region is clipped", function() local dst = core.buffer(4, 1) local src = core.buffer(4, 1) for i = 1, 4 do src:set(i, 1, 1.0) end -- write 10 frames starting at dst frame 3: clipped to the 2 that fit local n = dst:mix(src, 3, 1, 10) A.assert_equal(2, n) near(0.0, dst:get(2, 1)) near(1.0, dst:get(3, 1)) near(1.0, dst:get(4, 1)) end) -- ── oscillator + envelope ────────────────────────────────────────────── t:that("osc: sine fills the buffer and reports continuing phase", function() local b = core.buffer(48, 1) local phase = b:osc(0, 1000, 48000, 0, 1.0, 1, 48) A.assert_true(phase > 0 and phase < 1, "phase advances within a cycle") -- a 1 kHz sine sampled at 48 kHz: first sample 0, peaks near a quarter cycle near(0.0, b:get(1, 1), 1e-3, "sine starts at 0") A.assert_true(math.abs(b:get(13, 1)) > 0.9, "sine reaches near peak") end) t:that("osc: is additive over existing content", function() local b = core.buffer(8, 1) b:set(1, 1, 0.5) b:osc(0, 100, 48000, 0, 0.25, 1, 8) -- adds; first sine sample is 0 near(0.5, b:get(1, 1)) end) t:that("fade: linear ramp scales samples", function() local b = core.buffer(5, 1) for i = 1, 5 do b:set(i, 1, 1.0) end b:fade(0, 1, 1, 5) -- ramp 0 -> 1 across 5 frames near(0.0, b:get(1, 1)) near(1.0, b:get(5, 1)) near(0.5, b:get(3, 1)) end) -- ── PCM decode + resample ────────────────────────────────────────────── t:that("from_pcm: s16 decodes to [-1,1] floats", function() local b = sound.from_pcm(s16(0, 32767, -32768, 16384), 1, "s16") A.assert_equal(4, b:frames()) near(0.0, b:get(1, 1)) near(1.0, b:get(2, 1), 1e-3) near(-1.0, b:get(3, 1)) near(0.5, b:get(4, 1), 1e-3) end) t:that("from_pcm: stereo interleave is preserved", function() local b = sound.from_pcm(s16(32767, -32768, 16384, 0), 2, "s16") A.assert_equal(2, b:frames()) A.assert_equal(2, b:channels()) near(1.0, b:get(1, 1), 1e-3) near(-1.0, b:get(1, 2)) near(0.5, b:get(2, 1), 1e-3) near(0.0, b:get(2, 2)) end) t:that("resample: doubling the rate doubles the frame count", function() local src = core.buffer(100, 1) local dst = sound.resample(src, 24000, 48000) A.assert_equal(200, dst:frames()) end) -- ── WAV loader ───────────────────────────────────────────────────────── -- Build a minimal canonical 44-byte-header PCM WAV in memory. local function make_wav(channels, rate, bits, pcm) local function u16(v) return string.char(v % 256, math.floor(v / 256) % 256) end local function u32(v) return string.char( v % 256, math.floor(v / 256) % 256, math.floor(v / 65536) % 256, math.floor(v / 16777216) % 256 ) end local block_align = channels * (bits / 8) local byte_rate = rate * block_align local fmt = u16(1) .. u16(channels) .. u32(rate) .. u32(byte_rate) .. u16(block_align) .. u16(bits) local data = "data" .. u32(#pcm) .. pcm local body = "WAVE" .. "fmt " .. u32(16) .. fmt .. data return "RIFF" .. u32(#body) .. body end t:that("wav.decode: parses a 16-bit mono PCM WAV", function() local pcm = s16(0, 32767, -32768) local data = make_wav(1, 44100, 16, pcm) local buf, info = wav.decode(data) A.assert_not_nil(buf, info) A.assert_equal(1, info.channels) A.assert_equal(44100, info.rate) A.assert_equal(3, buf:frames()) near(1.0, buf:get(2, 1), 1e-3) end) t:that("wav.decode: resamples when opts.rate differs", function() local pcm = s16(0, 0, 0, 0) local buf, info = wav.decode(make_wav(1, 22050, 16, pcm), { rate = 44100 }) A.assert_not_nil(buf, info) A.assert_equal(44100, info.rate) A.assert_equal(8, buf:frames()) end) t:that("wav.decode: rejects non-RIFF data", function() A.assert_nil((wav.decode("not a wav file at all............"))) end) -- ── synth ────────────────────────────────────────────────────────────── t:that("synth.note_freq: A4 = 440, octave doubles", function() near(440.0, synth.note_freq("A4"), 0.01) near(880.0, synth.note_freq("A5"), 0.01) A.assert_nil(synth.note_freq("H9")) end) t:that("synth.tone: produces a buffer of the right length", function() local b = synth.tone({ freq = 440, dur = 0.1, rate = 48000, channels = 2 }) A.assert_equal(math.floor(0.1 * 48000 + 0.5), b:frames()) A.assert_equal(2, b:channels()) end) t:that("synth.tone: accepts a note name and a waveform", function() local b = sound.synth.tone({ note = "C5", dur = 0.05, wave = "square", amp = 0.3 }) A.assert_not_nil(b) -- the body of a square wave should hit its amplitude somewhere local hit = false for i = 1, b:frames() do if math.abs(b:get(i, 1)) > 0.25 then hit = true break end end A.assert_true(hit, "square tone reaches its amplitude") end) -- ── mixer ────────────────────────────────────────────────────────────── t:that("mixer: play/active/stop", function() local mx = sound.mixer({ channels = 2 }) A.assert_equal(0, mx:active()) local v = mx:play(core.buffer(10, 1)) A.assert_equal(1, mx:active()) A.assert_true(v:is_active()) v:stop() A.assert_equal(0, mx:active()) end) t:that("mixer: fill sums a voice into the output", function() local mx = sound.mixer({ channels = 2 }) local src = core.buffer(4, 2) for i = 1, 4 do src:set(i, 1, 0.5) src:set(i, 2, 0.5) end mx:play(src, { gain = 1.0, pan = 0 }) local out = core.buffer(4, 2) mx:fill(out, 4) -- centre pan is equal-power: 0.5 * cos(pi/4) ~= 0.3536 near(0.5 * math.cos(math.pi / 4), out:get(1, 1)) end) t:that("mixer: non-looping voice is reaped when exhausted", function() local mx = sound.mixer({ channels = 1 }) mx:play(core.buffer(4, 1)) local out = core.buffer(8, 1) mx:fill(out, 8) -- consumes all 4 frames in one fill A.assert_equal(0, mx:active(), "exhausted voice removed") end) t:that("mixer: looping voice keeps playing and wraps", function() local mx = sound.mixer({ channels = 1 }) local src = core.buffer(2, 1) src:set(1, 1, 0.1) src:set(2, 1, 0.2) mx:play(src, { loop = true }) local out = core.buffer(5, 1) mx:fill(out, 5) A.assert_equal(1, mx:active(), "looping voice stays active") near(0.1, out:get(1, 1)) near(0.2, out:get(2, 1)) near(0.1, out:get(3, 1)) -- wrapped near(0.1, out:get(5, 1)) end) t:conclude()