feat(trapos-music): add audio effects and guide
This commit is contained in:
parent
02190abf1e
commit
ff4e9e40cd
260
docs/audio_guide.md
Normal file
260
docs/audio_guide.md
Normal file
@ -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:
|
||||||
|
- <https://tweaked.cc/library/cc.audio.dfpwm.html>
|
||||||
|
- <https://tweaked.cc/peripheral/speaker.html>
|
||||||
|
- <https://tweaked.cc/guide/speaker_audio.html>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "TrapOS",
|
"name": "TrapOS",
|
||||||
"version": "0.12.6",
|
"version": "0.12.7",
|
||||||
"branch": "master",
|
"branch": "master",
|
||||||
"packages": [
|
"packages": [
|
||||||
"trapos"
|
"trapos"
|
||||||
|
|||||||
@ -2,12 +2,12 @@
|
|||||||
"packages": {
|
"packages": {
|
||||||
"trapos-core": "0.9.5",
|
"trapos-core": "0.9.5",
|
||||||
"trapos-test": "0.2.3",
|
"trapos-test": "0.2.3",
|
||||||
"trapos-boot": "0.4.4",
|
"trapos-boot": "0.4.5",
|
||||||
"trapos-ui": "0.2.4",
|
"trapos-ui": "0.2.4",
|
||||||
"trapos-ai": "0.8.0",
|
"trapos-ai": "0.8.0",
|
||||||
"trapos-cloud": "0.4.5",
|
"trapos-cloud": "0.4.5",
|
||||||
"trapos-music": "0.1.1",
|
"trapos-music": "0.2.0",
|
||||||
"trapos-sandbox-legacy": "0.3.2",
|
"trapos-sandbox-legacy": "0.3.2",
|
||||||
"trapos": "0.12.6"
|
"trapos": "0.12.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos-boot",
|
"name": "trapos-boot",
|
||||||
"version": "0.4.4",
|
"version": "0.4.5",
|
||||||
"description": "TrapOS boot: startup MOTD and autostart server launcher",
|
"description": "TrapOS boot: startup MOTD and autostart server launcher",
|
||||||
"dependencies": ["trapos-core"],
|
"dependencies": ["trapos-core"],
|
||||||
"files": ["programs/motd.lua", "startup/boot.lua"],
|
"files": ["programs/motd.lua", "startup/boot.lua"],
|
||||||
|
|||||||
203
packages/trapos-music/apis/libaudio.lua
Normal file
203
packages/trapos-music/apis/libaudio.lua
Normal file
@ -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;
|
||||||
@ -8,7 +8,11 @@
|
|||||||
|
|
||||||
local DEFAULT_DIR = '/data/musics';
|
local DEFAULT_DIR = '/data/musics';
|
||||||
local EXTENSION = '.dfpwm';
|
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)
|
local function createMusic(opts)
|
||||||
opts = opts or {};
|
opts = opts or {};
|
||||||
@ -75,6 +79,7 @@ local function createMusic(opts)
|
|||||||
local pendingBuffer = nil; -- buffer that playAudio refused, replayed first
|
local pendingBuffer = nil; -- buffer that playAudio refused, replayed first
|
||||||
local currentPath = nil;
|
local currentPath = nil;
|
||||||
local onFinishCb = nil;
|
local onFinishCb = nil;
|
||||||
|
local effect = nil; -- optional libaudio filter applied to each decoded buffer
|
||||||
|
|
||||||
local function getSpeaker()
|
local function getSpeaker()
|
||||||
local speaker = peripheralLib.find('speaker');
|
local speaker = peripheralLib.find('speaker');
|
||||||
@ -129,8 +134,11 @@ local function createMusic(opts)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local buffer = decoder(chunk);
|
local buffer = decoder(chunk);
|
||||||
|
if effect then
|
||||||
|
buffer = effect.process(buffer);
|
||||||
|
end
|
||||||
if not speaker.playAudio(buffer) then
|
if not speaker.playAudio(buffer) then
|
||||||
pendingBuffer = buffer;
|
pendingBuffer = buffer; -- already processed; not re-filtered on retry
|
||||||
return;
|
return;
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -154,11 +162,20 @@ local function createMusic(opts)
|
|||||||
player.stop();
|
player.stop();
|
||||||
handle = assert(ioLib.open(path, 'rb'), 'cannot open ' .. tostring(path));
|
handle = assert(ioLib.open(path, 'rb'), 'cannot open ' .. tostring(path));
|
||||||
decoder = dfpwm.make_decoder();
|
decoder = dfpwm.make_decoder();
|
||||||
|
if effect then
|
||||||
|
effect.reset(); -- fresh filter state so no tail bleeds across tracks
|
||||||
|
end
|
||||||
currentPath = path;
|
currentPath = path;
|
||||||
status = 'playing';
|
status = 'playing';
|
||||||
pump();
|
pump();
|
||||||
end
|
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
|
-- Gapless pause: stop feeding and let already-queued audio drain. The
|
||||||
-- decoder/handle/pendingBuffer are kept so resume loses no content.
|
-- decoder/handle/pendingBuffer are kept so resume loses no content.
|
||||||
function player.pause()
|
function player.pause()
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos-music",
|
"name": "trapos-music",
|
||||||
"version": "0.1.1",
|
"version": "0.2.0",
|
||||||
"description": "TrapOS music player: dfpwm playback through a speaker",
|
"description": "TrapOS music player: dfpwm playback through a speaker",
|
||||||
"dependencies": ["trapos-core", "trapos-ui"],
|
"dependencies": ["trapos-core", "trapos-ui"],
|
||||||
"files": [
|
"files": [
|
||||||
"apis/libmusic.lua",
|
"apis/libmusic.lua",
|
||||||
|
"apis/libaudio.lua",
|
||||||
"programs/music.lua",
|
"programs/music.lua",
|
||||||
"data/musics/GrillControl.dfpwm",
|
"data/musics/GrillControl.dfpwm",
|
||||||
"data/musics/trap-dirty-techno.dfpwm"
|
"data/musics/trap-dirty-techno.dfpwm"
|
||||||
|
|||||||
@ -41,6 +41,8 @@ end
|
|||||||
|
|
||||||
local createEventLoop = require('/apis/eventloop');
|
local createEventLoop = require('/apis/eventloop');
|
||||||
local createTui = require('/apis/libtui');
|
local createTui = require('/apis/libtui');
|
||||||
|
local createAudio = require('/apis/libaudio');
|
||||||
|
local audio = createAudio();
|
||||||
|
|
||||||
local eventloop = createEventLoop();
|
local eventloop = createEventLoop();
|
||||||
local ui = createTui(eventloop);
|
local ui = createTui(eventloop);
|
||||||
@ -52,9 +54,20 @@ local List = ui.List;
|
|||||||
|
|
||||||
local player = music.createPlayer(eventloop);
|
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,
|
-- Rows of UI chrome around the track list (header, now-playing, controls,
|
||||||
-- footer, plus the track box border). Used to size the scroll window.
|
-- effects, footer, plus the track box border). Used to size the scroll window.
|
||||||
local LIST_CHROME = 6;
|
local LIST_CHROME = 7;
|
||||||
|
|
||||||
local selectedIndex = 1; -- list cursor
|
local selectedIndex = 1; -- list cursor
|
||||||
local scrollOffset = 0; -- index of the first visible row - 1
|
local scrollOffset = 0; -- index of the first visible row - 1
|
||||||
@ -108,6 +121,30 @@ player.onFinish(function()
|
|||||||
ui.rerender();
|
ui.rerender();
|
||||||
end);
|
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()
|
local function Header()
|
||||||
return Box({
|
return Box({
|
||||||
direction = 'row',
|
direction = 'row',
|
||||||
@ -222,9 +259,33 @@ local function Controls()
|
|||||||
});
|
});
|
||||||
end
|
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()
|
local function Footer()
|
||||||
return Text(
|
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 }
|
{ color = colors.lightGray }
|
||||||
);
|
);
|
||||||
end
|
end
|
||||||
@ -244,6 +305,7 @@ local function App()
|
|||||||
}),
|
}),
|
||||||
NowPlaying(),
|
NowPlaying(),
|
||||||
Controls(),
|
Controls(),
|
||||||
|
Effects(),
|
||||||
Footer(),
|
Footer(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -277,6 +339,12 @@ eventloop.register('key', function(key)
|
|||||||
playIndex(nextIndex());
|
playIndex(nextIndex());
|
||||||
elseif key == keys.p then
|
elseif key == keys.p then
|
||||||
playIndex(previousIndex());
|
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
|
elseif key == keys.q then
|
||||||
ui.exitUI('quit');
|
ui.exitUI('quit');
|
||||||
end
|
end
|
||||||
|
|||||||
93
packages/trapos-music/tests/libaudio.lua
Normal file
93
packages/trapos-music/tests/libaudio.lua
Normal file
@ -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();
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos",
|
"name": "trapos",
|
||||||
"version": "0.12.6",
|
"version": "0.12.7",
|
||||||
"description": "TrapOS full install meta-package",
|
"description": "TrapOS full install meta-package",
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
"trapos-boot",
|
"trapos-boot",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user