From ff4e9e40cd136f275ce194326a0960c01cd6e69b Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Sat, 20 Jun 2026 14:15:28 +0200 Subject: [PATCH] feat(trapos-music): add audio effects and guide --- docs/audio_guide.md | 260 +++++++++++++++++++++++ manifest.json | 2 +- packages/index.json | 6 +- packages/trapos-boot/ccpm.json | 2 +- packages/trapos-music/apis/libaudio.lua | 203 ++++++++++++++++++ packages/trapos-music/apis/libmusic.lua | 21 +- packages/trapos-music/ccpm.json | 3 +- packages/trapos-music/programs/music.lua | 74 ++++++- packages/trapos-music/tests/libaudio.lua | 93 ++++++++ packages/trapos/ccpm.json | 2 +- 10 files changed, 654 insertions(+), 12 deletions(-) create mode 100644 docs/audio_guide.md create mode 100644 packages/trapos-music/apis/libaudio.lua create mode 100644 packages/trapos-music/tests/libaudio.lua diff --git a/docs/audio_guide.md b/docs/audio_guide.md new file mode 100644 index 0000000..5a33d8b --- /dev/null +++ b/docs/audio_guide.md @@ -0,0 +1,260 @@ +# ComputerCraft Audio Guide + +Consolidated reference for playing audio on CC: Tweaked. Covers the `cc.audio.dfpwm` +library, the `speaker` peripheral, and the practical guide to streaming PCM audio. + +Sources: +- +- +- + +--- + +## 1. Audio fundamentals + +### PCM (Pulse-code Modulation) +Sound is an analog, continuous signal. Computers need discrete data, so audio is +**sampled** at a fixed rate and each sample **quantized** to a representable value. +A long, uniformly sampled list of amplitudes is called **PCM**. + +### Speaker specifications +CC: Tweaked speakers play back: +- **48,000 samples per second** (48 kHz) +- each sample is an **integer between -128 and 127** (8-bit resolution) + +So one second of audio = 48,000 samples. One minute ≈ 2.88 million samples — which is +why streaming (processing in chunks) is preferred over building one giant buffer. + +### DFPWM +**DFPWM (Dynamic Filter Pulse Width Modulation)** is a codec by GreaseMonkey, the de +facto standard audio format in the ComputerCraft / OpenComputers world. It encodes at +**1 bit per sample**, making it compact for storage while remaining simple enough for +real-time processing. CC: Tweaked ships the built-in `cc.audio.dfpwm` module to +encode/decode it. You convert DFPWM → PCM amplitudes, then feed those to the speaker. + +--- + +## 2. `cc.audio.dfpwm` library + +`require("cc.audio.dfpwm")` — converts between DFPWM streams and PCM amplitude tables. + +> **Stateful warning:** Encoders and decoders keep internal state about the current +> stream. Reusing one encoder/decoder across multiple streams — or using different +> ones on the same stream — produces corrupt audio. One stream = one +> encoder/decoder. + +### `make_encoder()` +Creates a new encoder for converting PCM audio to DFPWM. + +- **Returns:** a function. Call it with a table of amplitude values (-128 to 127); + it returns encoded DFPWM data as a string. +- Use this (not `encode`) when writing multiple chunks to the same location. + +### `encode(input)` +Convenience function to encode a whole file at once. + +- **`input`** — table of amplitude numbers. +- **Returns:** string of encoded DFPWM data. +- Prefer `make_encoder()` for chunked/multi-write output. + +### `make_decoder()` +Creates a new decoder for converting DFPWM data to PCM amplitudes. + +- **Returns:** a function. Call it with a DFPWM string; it returns a table of + amplitude values (-128 to 127). + +### `decode(input)` +Convenience function to decode a whole file at once. + +- **`input`** — string of DFPWM data. +- **Returns:** table of amplitude values. +- For larger files, use `make_decoder()` with chunked reading instead. + +--- + +## 3. `speaker` peripheral + +Three ways to produce audio, increasing in complexity: note block notes, Minecraft +sounds, and arbitrary PCM streaming. + +### `playNote(instrument [, volume [, pitch]])` — *New in 1.80pr1* +Plays a note block note. + +- **`instrument`** (string, required): `"harp"`, `"basedrum"`, `"snare"`, `"hat"`, + `"bass"`, `"flute"`, `"bell"`, `"guitar"`, `"chime"`, `"xylophone"`, + `"iron_xylophone"`, `"cow_bell"`, `"didgeridoo"`, `"bit"`, `"banjo"`, `"pling"`. +- **`volume`** (number, optional): `0.0`–`3.0`, default `1.0`. +- **`pitch`** (number, optional): semitones `0`–`24`, default `12`. +- **Returns:** boolean — `false` if the 8-notes-per-tick limit was reached. +- **Throws:** if the instrument doesn't exist. + +### `playSound(name [, volume [, pitch]])` — *New in 1.80pr1* +Plays a Minecraft sound effect (vanilla or modded). + +- **`name`** (string, required): sound id, e.g. `"entity.creeper.primed"`. +- **`volume`** (number, optional): `0.0`–`3.0`, default `1.0`. +- **`pitch`** (number, optional): speed multiplier `0.5`–`2.0`, default `1.0`. +- **Returns:** boolean — `false` if another sound already started this tick or audio + is currently playing. +- **Throws:** if the sound name is invalid. + +```lua +local speaker = peripheral.find("speaker") +speaker.playSound("entity.creeper.primed") +``` + +### `playAudio(audio [, volume])` — *New in 1.100* +Streams arbitrary audio as 8-bit PCM samples at 48 kHz. + +- **`audio`** (table, required): list of amplitude values between -128 and 127. +- **`volume`** (number, optional): if omitted, reuses the previous volume. +- **Returns:** boolean — `true` if the buffer accepted the data, `false` if the + speaker's backlog is full. +- **Throws:** if the audio data is malformed. +- **Notes:** + - Keep chunks to a max of **128 × 1024 (128k) samples** per call to minimize + stuttering. + - When it returns `false`, wait for the **`speaker_audio_empty`** event before + retrying. + - Samples are re-encoded before playback. + +```lua +local dfpwm = require("cc.audio.dfpwm") +local speaker = peripheral.find("speaker") +local decoder = dfpwm.make_decoder() + +for chunk in io.lines("data/example.dfpwm", 16 * 1024) do + local buffer = decoder(chunk) + while not speaker.playAudio(buffer) do + os.pullEvent("speaker_audio_empty") + end +end +``` + +### `stop()` — *New in 1.100* +Stops all playback from this speaker: clears the queued audio and halts any active +sound. Returns nothing. + +--- + +## 4. The core streaming pattern + +This is the loop you'll reuse for almost all PCM playback: + +```lua +while not speaker.playAudio(buffer) do + os.pullEvent("speaker_audio_empty") +end +``` + +The speaker rejects new chunks when its backlog is too large — `playAudio` returns +`false`. Wait for `speaker_audio_empty`, then retry the same buffer. + +--- + +## 5. Examples + +### Generate a sine wave (in-memory buffer) +220 Hz hum, ~2.7 seconds: + +```lua +local speaker = peripheral.find("speaker") + +local buffer = {} +local t, dt = 0, 2 * math.pi * 220 / 48000 +for i = 1, 128 * 1024 do + buffer[i] = math.floor(math.sin(t) * 127) + t = (t + dt) % (math.pi * 2) +end + +speaker.playAudio(buffer) +``` + +### Stream an endless sine wave (chunked) +Avoids allocating millions of samples up front: + +```lua +local speaker = peripheral.find("speaker") + +local t, dt = 0, 2 * math.pi * 220 / 48000 +while true do + local buffer = {} + for i = 1, 16 * 1024 * 8 do + buffer[i] = math.floor(math.sin(t) * 127) + t = (t + dt) % (math.pi * 2) + end + + while not speaker.playAudio(buffer) do + os.pullEvent("speaker_audio_empty") + end +end +``` + +### Play a DFPWM file +Read in 16 KiB chunks, decode, play: + +```lua +local dfpwm = require("cc.audio.dfpwm") +local speaker = peripheral.find("speaker") + +local decoder = dfpwm.make_decoder() +for chunk in io.lines("data/example.dfpwm", 16 * 1024) do + local buffer = decoder(chunk) + + while not speaker.playAudio(buffer) do + os.pullEvent("speaker_audio_empty") + end +end +``` + +### Delay / echo effect (ring buffer) +PCM's simplicity lets you mix and manipulate samples directly. Mix streams by adding +amplitudes; change playback rate by dropping samples; etc. This adds a 1.5-second +delay. Scale values (0.6 original + 0.4 delayed) to avoid clipping past -128..127: + +```lua +local dfpwm = require("cc.audio.dfpwm") +local speaker = peripheral.find("speaker") + +local samples_i, samples_n = 1, 48000 * 1.5 +local samples = {} +for i = 1, samples_n do samples[i] = 0 end + +local decoder = dfpwm.make_decoder() +for chunk in io.lines("data/example.dfpwm", 16 * 1024) do + local buffer = decoder(chunk) + + for i = 1, #buffer do + local original_value = buffer[i] + + buffer[i] = original_value * 0.6 + samples[samples_i] * 0.4 + + samples[samples_i] = original_value + samples_i = samples_i + 1 + if samples_i > samples_n then samples_i = 1 end + end + + while not speaker.playAudio(buffer) do + os.pullEvent("speaker_audio_empty") + end + + sleep(0.05) +end +``` + +--- + +## 6. Quick reference + +| Need | Use | +| --- | --- | +| Note block tone | `speaker.playNote(instrument, vol, pitch)` | +| Minecraft sound effect | `speaker.playSound(name, vol, pitch)` | +| Arbitrary PCM playback | `speaker.playAudio(buffer, vol)` | +| Stop everything | `speaker.stop()` | +| Decode DFPWM → PCM | `dfpwm.make_decoder()` / `dfpwm.decode(s)` | +| Encode PCM → DFPWM | `dfpwm.make_encoder()` / `dfpwm.encode(t)` | +| Backpressure signal | `os.pullEvent("speaker_audio_empty")` | + +**Key constants:** 48 kHz sample rate · 8-bit samples (-128..127) · ≤128k samples per +`playAudio` call · 16 KiB is a common DFPWM chunk size. diff --git a/manifest.json b/manifest.json index dd0224f..307f634 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "name": "TrapOS", - "version": "0.12.6", + "version": "0.12.7", "branch": "master", "packages": [ "trapos" diff --git a/packages/index.json b/packages/index.json index de9597d..dc42660 100644 --- a/packages/index.json +++ b/packages/index.json @@ -2,12 +2,12 @@ "packages": { "trapos-core": "0.9.5", "trapos-test": "0.2.3", - "trapos-boot": "0.4.4", + "trapos-boot": "0.4.5", "trapos-ui": "0.2.4", "trapos-ai": "0.8.0", "trapos-cloud": "0.4.5", - "trapos-music": "0.1.1", + "trapos-music": "0.2.0", "trapos-sandbox-legacy": "0.3.2", - "trapos": "0.12.6" + "trapos": "0.12.7" } } diff --git a/packages/trapos-boot/ccpm.json b/packages/trapos-boot/ccpm.json index cac1356..2ce4266 100644 --- a/packages/trapos-boot/ccpm.json +++ b/packages/trapos-boot/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos-boot", - "version": "0.4.4", + "version": "0.4.5", "description": "TrapOS boot: startup MOTD and autostart server launcher", "dependencies": ["trapos-core"], "files": ["programs/motd.lua", "startup/boot.lua"], diff --git a/packages/trapos-music/apis/libaudio.lua b/packages/trapos-music/apis/libaudio.lua new file mode 100644 index 0000000..7d640f0 --- /dev/null +++ b/packages/trapos-music/apis/libaudio.lua @@ -0,0 +1,203 @@ +-- libaudio: simple stateful DSP filters for 8-bit PCM audio buffers. +-- +-- A factory: `local createAudio = require('/apis/libaudio'); local audio = createAudio();` +-- +-- Each constructor returns a *filter instance* operating on a buffer (a table of +-- amplitudes -128..127, the same shape the dfpwm decoder produces and the +-- speaker consumes). Filters are stateful across chunks -- one instance per +-- playback, exactly like a dfpwm decoder. They share a uniform shape so a UI can +-- drive any of them generically: +-- +-- filter.process(buffer) -- in-place transform, returns the buffer +-- filter.set(value) -- set the primary tunable param (keeps history) +-- filter.get() -- current param value +-- filter.reset() -- clear internal state (call on track switch) +-- filter.spec -- { name, param, unit, min, max, step, format } +-- +-- `sampleRate` is injectable so the delay-based filters are unit-testable with a +-- tiny, exact ring length outside the CC runtime (mirrors libmusic's style). + +local DEFAULT_SAMPLE_RATE = 48000; + +-- Round to the nearest representable 8-bit sample and clamp to range. +local function clamp(v) + v = math.floor(v + 0.5); + if v > 127 then return 127; end + if v < -128 then return -128; end + return v; +end + +local function clampParam(value, spec) + if value < spec.min then return spec.min; end + if value > spec.max then return spec.max; end + return value; +end + +local function createAudio(opts) + opts = opts or {}; + local sampleRate = opts.sampleRate or DEFAULT_SAMPLE_RATE; + + local api = {}; + + -- One-pole IIR low-pass: y = y_prev + alpha*(x - y_prev). Higher cutoff lets + -- more treble through; very low cutoff muffles the sound to its bass. + function api.lowPass(cutoffHz) + local f = {}; + f.spec = { name = 'Low-pass', param = 'Cutoff', unit = 'Hz', + min = 100, max = 12000, step = 100 }; + + local yPrev = 0; -- float; only the written sample is clamped + local alpha = 0; + local cutoff = 0; + + f.set = function(value) + cutoff = clampParam(value, f.spec); + local rc = 1 / (2 * math.pi * cutoff); + local dt = 1 / sampleRate; + alpha = dt / (rc + dt); + end + f.get = function() return cutoff; end + f.reset = function() yPrev = 0; end + f.process = function(buffer) + for i = 1, #buffer do + yPrev = yPrev + alpha * (buffer[i] - yPrev); + buffer[i] = clamp(yPrev); + end + return buffer; + end + + f.set(cutoffHz or 800); + return f; + end + + -- One-pole IIR high-pass: y = alpha*(y_prev + x - x_prev). Strips bass; high + -- cutoff leaves only a thin, tinny top end. + function api.highPass(cutoffHz) + local f = {}; + f.spec = { name = 'High-pass', param = 'Cutoff', unit = 'Hz', + min = 100, max = 12000, step = 100 }; + + local yPrev = 0; + local xPrev = 0; + local alpha = 0; + local cutoff = 0; + + f.set = function(value) + cutoff = clampParam(value, f.spec); + local rc = 1 / (2 * math.pi * cutoff); + local dt = 1 / sampleRate; + alpha = rc / (rc + dt); + end + f.get = function() return cutoff; end + f.reset = function() yPrev = 0; xPrev = 0; end + f.process = function(buffer) + for i = 1, #buffer do + local x = buffer[i]; + yPrev = alpha * (yPrev + x - xPrev); + xPrev = x; + buffer[i] = clamp(yPrev); + end + return buffer; + end + + f.set(cutoffHz or 2000); + return f; + end + + -- Single-tap delay (audio guide section 5): mixes 0.6 of the dry signal with + -- 0.4 of the signal from `time` ms ago, via a ring buffer. + function api.delay(timeMs) + local f = {}; + f.spec = { name = 'Delay', param = 'Time', unit = 'ms', + min = 50, max = 1500, step = 50 }; + + local timeValue = 0; + local ring = {}; + local ringLen = 1; + local idx = 1; + + local function rebuild() + ringLen = math.max(1, math.floor(timeValue / 1000 * sampleRate + 0.5)); + ring = {}; + for i = 1, ringLen do ring[i] = 0; end + idx = 1; + end + + f.set = function(value) + timeValue = clampParam(value, f.spec); + rebuild(); + end + f.get = function() return timeValue; end + f.reset = function() rebuild(); end + f.process = function(buffer) + for i = 1, #buffer do + local original = buffer[i]; + local mixed = original * 0.6 + ring[idx] * 0.4; + ring[idx] = original; + idx = idx + 1; + if idx > ringLen then idx = 1; end + buffer[i] = clamp(mixed); + end + return buffer; + end + + f.set(timeMs or 300); + return f; + end + + -- Schroeder-style reverb: a few parallel feedback comb filters whose decaying + -- echoes are summed into a wet signal, then mixed with the dry. Each comb is + -- y = (1-g)*x + g*y[n-D]: the (1-g) input gain keeps the tap in range whatever + -- the feedback, so `decay` (g) purely controls how long the tail rings out. + -- An impulse leaves echoes at D, 2D, 3D... decaying by g, i.e. an audible tail. + local COMB_MS = { 50, 56, 61, 68 }; + function api.reverb(decay) + local f = {}; + f.spec = { name = 'Reverb', param = 'Decay', unit = '', + min = 0, max = 0.9, step = 0.1, format = '%.1f' }; + + local g = 0; + local combs = {}; + + local function rebuild() + combs = {}; + for c = 1, #COMB_MS do + local len = math.max(1, math.floor(COMB_MS[c] / 1000 * sampleRate + 0.5)); + local buf = {}; + for i = 1, len do buf[i] = 0; end + combs[c] = { buf = buf, len = len, idx = 1 }; + end + end + + f.set = function(value) + g = clampParam(value, f.spec); + end + f.get = function() return g; end + f.reset = function() rebuild(); end + f.process = function(buffer) + for i = 1, #buffer do + local x = buffer[i]; + local wet = 0; + for c = 1, #combs do + local comb = combs[c]; + local y = (1 - g) * x + g * comb.buf[comb.idx]; + comb.buf[comb.idx] = y; + comb.idx = comb.idx + 1; + if comb.idx > comb.len then comb.idx = 1; end + wet = wet + y; + end + wet = wet / #combs; + buffer[i] = clamp(x * 0.4 + wet * 0.6); + end + return buffer; + end + + rebuild(); + f.set(decay or 0.5); + return f; + end + + return api; +end + +return createAudio; diff --git a/packages/trapos-music/apis/libmusic.lua b/packages/trapos-music/apis/libmusic.lua index 6729dd6..ebbe908 100644 --- a/packages/trapos-music/apis/libmusic.lua +++ b/packages/trapos-music/apis/libmusic.lua @@ -8,7 +8,11 @@ local DEFAULT_DIR = '/data/musics'; local EXTENSION = '.dfpwm'; -local CHUNK_SIZE = 16 * 1024; +-- Small chunks keep little audio queued ahead of the speaker, so live effect +-- and parameter changes are heard almost immediately (at the cost of more +-- decode calls per second). One byte of dfpwm decodes to 8 samples, so this is +-- ~0.34s of audio per chunk. +local CHUNK_SIZE = 2 * 1024; local function createMusic(opts) opts = opts or {}; @@ -75,6 +79,7 @@ local function createMusic(opts) local pendingBuffer = nil; -- buffer that playAudio refused, replayed first local currentPath = nil; local onFinishCb = nil; + local effect = nil; -- optional libaudio filter applied to each decoded buffer local function getSpeaker() local speaker = peripheralLib.find('speaker'); @@ -129,8 +134,11 @@ local function createMusic(opts) end local buffer = decoder(chunk); + if effect then + buffer = effect.process(buffer); + end if not speaker.playAudio(buffer) then - pendingBuffer = buffer; + pendingBuffer = buffer; -- already processed; not re-filtered on retry return; end end @@ -154,11 +162,20 @@ local function createMusic(opts) player.stop(); handle = assert(ioLib.open(path, 'rb'), 'cannot open ' .. tostring(path)); decoder = dfpwm.make_decoder(); + if effect then + effect.reset(); -- fresh filter state so no tail bleeds across tracks + end currentPath = path; status = 'playing'; pump(); end + -- Sets the active audio effect (a libaudio filter instance), or nil for off. + -- Takes effect on the next decoded buffer; safe to swap mid-playback. + function player.setEffect(filter) + effect = filter; + end + -- Gapless pause: stop feeding and let already-queued audio drain. The -- decoder/handle/pendingBuffer are kept so resume loses no content. function player.pause() diff --git a/packages/trapos-music/ccpm.json b/packages/trapos-music/ccpm.json index 4c80260..ca46fbb 100644 --- a/packages/trapos-music/ccpm.json +++ b/packages/trapos-music/ccpm.json @@ -1,10 +1,11 @@ { "name": "trapos-music", - "version": "0.1.1", + "version": "0.2.0", "description": "TrapOS music player: dfpwm playback through a speaker", "dependencies": ["trapos-core", "trapos-ui"], "files": [ "apis/libmusic.lua", + "apis/libaudio.lua", "programs/music.lua", "data/musics/GrillControl.dfpwm", "data/musics/trap-dirty-techno.dfpwm" diff --git a/packages/trapos-music/programs/music.lua b/packages/trapos-music/programs/music.lua index 0810524..91fa1bd 100644 --- a/packages/trapos-music/programs/music.lua +++ b/packages/trapos-music/programs/music.lua @@ -41,6 +41,8 @@ end local createEventLoop = require('/apis/eventloop'); local createTui = require('/apis/libtui'); +local createAudio = require('/apis/libaudio'); +local audio = createAudio(); local eventloop = createEventLoop(); local ui = createTui(eventloop); @@ -52,9 +54,20 @@ local List = ui.List; local player = music.createPlayer(eventloop); +-- Ordered effect menu; `make` builds a fresh stateful filter (nil = bypass). +local effects = { + { label = 'Off', make = nil }, + { label = 'Low-pass', make = function() return audio.lowPass(); end }, + { label = 'High-pass', make = function() return audio.highPass(); end }, + { label = 'Delay', make = function() return audio.delay(); end }, + { label = 'Reverb', make = function() return audio.reverb(); end }, +}; +local effectIndex = 1; +local currentFilter = nil; + -- Rows of UI chrome around the track list (header, now-playing, controls, --- footer, plus the track box border). Used to size the scroll window. -local LIST_CHROME = 6; +-- effects, footer, plus the track box border). Used to size the scroll window. +local LIST_CHROME = 7; local selectedIndex = 1; -- list cursor local scrollOffset = 0; -- index of the first visible row - 1 @@ -108,6 +121,30 @@ player.onFinish(function() ui.rerender(); end); +-- Switches to effect `index`, building a fresh filter and handing it to the +-- player (nil for 'Off' = bypass). +local function applyEffect(index) + effectIndex = (index - 1) % #effects + 1; + local entry = effects[effectIndex]; + currentFilter = entry.make and entry.make() or nil; + player.setEffect(currentFilter); + ui.rerender(); +end + +local function cycleEffect() + applyEffect(effectIndex + 1); +end + +-- Steps the active filter's tunable parameter by one `spec.step` (dir = +/-1). +local function adjustParam(dir) + if not currentFilter then + return; + end + local spec = currentFilter.spec; + currentFilter.set(currentFilter.get() + dir * spec.step); + ui.rerender(); +end + local function Header() return Box({ direction = 'row', @@ -222,9 +259,33 @@ local function Controls() }); end +local function Effects() + local entry = effects[effectIndex]; + local children = { + Button('FX: ' .. entry.label, { + flex = 1, + onClick = cycleEffect, + }), + }; + if currentFilter then + local spec = currentFilter.spec; + local value = currentFilter.get(); + local shown = spec.format and string.format(spec.format, value) or tostring(value); + local unit = spec.unit ~= '' and (' ' .. spec.unit) or ''; + children[#children + 1] = Button('-', { onClick = function() adjustParam(-1); end }); + children[#children + 1] = Text(spec.param .. ': ' .. shown .. unit); + children[#children + 1] = Button('+', { onClick = function() adjustParam(1); end }); + end + return Box({ + direction = 'row', + gap = 1, + children = children, + }); +end + local function Footer() return Text( - 'up/down select enter play space pause s stop n/p next/prev q quit', + 'enter play space pause s stop n/p next/prev f fx [ ] tune q quit', { color = colors.lightGray } ); end @@ -244,6 +305,7 @@ local function App() }), NowPlaying(), Controls(), + Effects(), Footer(), }, }); @@ -277,6 +339,12 @@ eventloop.register('key', function(key) playIndex(nextIndex()); elseif key == keys.p then playIndex(previousIndex()); + elseif key == keys.f then + cycleEffect(); + elseif key == keys.leftBracket then + adjustParam(-1); + elseif key == keys.rightBracket then + adjustParam(1); elseif key == keys.q then ui.exitUI('quit'); end diff --git a/packages/trapos-music/tests/libaudio.lua b/packages/trapos-music/tests/libaudio.lua new file mode 100644 index 0000000..50b7e8d --- /dev/null +++ b/packages/trapos-music/tests/libaudio.lua @@ -0,0 +1,93 @@ +local createLibTest = require('/apis/libtest'); +local createAudio = require('/apis/libaudio'); + +local testlib = createLibTest({ ... }); + +local function constant(value, n) + local buf = {}; + for i = 1, n do buf[i] = value; end + return buf; +end + +local function impulse(value, n) + local buf = constant(0, n); + buf[1] = value; + return buf; +end + +local function allInRange(buf) + for i = 1, #buf do + if buf[i] < -128 or buf[i] > 127 or buf[i] ~= math.floor(buf[i]) then + return false; + end + end + return true; +end + +testlib.test('low-pass passes DC and stays in range, rising toward the input', function() + local audio = createAudio(); + local lp = audio.lowPass(); + local out = lp.process(constant(127, 2000)); + testlib.assertTrue(allInRange(out), 'samples must be valid 8-bit integers'); + testlib.assertTrue(out[#out] > out[1], 'output should rise toward the DC level'); + testlib.assertTrue(out[#out] <= 127, 'output must not exceed the DC level'); +end); + +testlib.test('high-pass blocks DC: a constant decays toward zero', function() + local audio = createAudio(); + local hp = audio.highPass(); + local out = hp.process(constant(100, 200)); + testlib.assertTrue(allInRange(out), 'samples must be valid 8-bit integers'); + testlib.assertTrue(math.abs(out[#out]) < math.abs(out[1]), 'DC component should decay'); + testlib.assertTrue(math.abs(out[#out]) <= 1, 'DC should fall close to zero'); +end); + +testlib.test('set changes the response and reset clears state', function() + local audio = createAudio(); + local lp = audio.lowPass(); + + lp.set(200); + local low = lp.process(constant(127, 1))[1]; + lp.reset(); + lp.set(5000); + local high = lp.process(constant(127, 1))[1]; + testlib.assertTrue(low ~= high, 'different cutoffs should give a different first sample'); + + -- reset must make a repeated run identical + lp.reset(); + local a = lp.process(constant(50, 16)); + lp.reset(); + local b = lp.process(constant(50, 16)); + testlib.assertEquals(a[#a], b[#b]); +end); + +testlib.test('set clamps the param to the spec range', function() + local audio = createAudio(); + local rv = audio.reverb(); + rv.set(10); + testlib.assertEquals(rv.get(), 0.9); + rv.set(-5); + testlib.assertEquals(rv.get(), 0); +end); + +testlib.test('delay re-emits the dry signal after the ring length', function() + local audio = createAudio({ sampleRate = 8 }); + local d = audio.delay(1000); -- 1000ms * 8/s = 8-sample ring + local out = d.process(impulse(100, 10)); + + testlib.assertTrue(allInRange(out), 'samples must be valid 8-bit integers'); + testlib.assertEquals(out[1], 60); -- 100 * 0.6 dry + testlib.assertEquals(out[9], 40); -- 100 * 0.4 delayed by 8 samples + testlib.assertEquals(out[5], 0); -- nothing in between +end); + +testlib.test('reverb stays bounded for a loud impulse', function() + local audio = createAudio(); + local rv = audio.reverb(0.8); + local buf = constant(0, 4000); + for i = 1, 200 do buf[i] = 127; end + local out = rv.process(buf); + testlib.assertTrue(allInRange(out), 'reverb output must never clip past 8-bit range'); +end); + +testlib.run(); diff --git a/packages/trapos/ccpm.json b/packages/trapos/ccpm.json index eb193e7..6205f9b 100644 --- a/packages/trapos/ccpm.json +++ b/packages/trapos/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos", - "version": "0.12.6", + "version": "0.12.7", "description": "TrapOS full install meta-package", "dependencies": [ "trapos-boot",