Board::PWMAudio (Audio)
The Harucom Board has stereo PWM audio output. Plug headphones or speakers into the 3.5mm jack and the board makes sound.
Sound is played through an 8-channel mixer. Each channel plays one source: a waveform (sine, square, triangle, sawtooth) or a WAV or QOA sample. The rendering runs on its own in C, so Ruby only changes parameters.
Contents
- Getting Started
- Playing Sound
- Board::PWMAudio#tone
- Board::PWMAudio#beep
- Board::PWMAudio#stop
- Board::PWMAudio#stop_all
- Board::PWMAudio#pan
- Board::PWMAudio#mute
- Board::PWMAudio#channel
- Board::PWMAudio#load_sample
- Board::PWMAudio#sample_clock
- Board::PWMAudio#tone_at
- Board::PWMAudio#play_at
- Board::PWMAudio#stop_at
- Board::PWMAudio#cancel_scheduled
- Board::PWMAudio#deinit
- Using the Channel Object
- PWMAudio::Channel#source=
- PWMAudio::Channel#source
- PWMAudio::Channel#play
- PWMAudio::Channel#play_at
- PWMAudio::Channel#tone
- PWMAudio::Channel#tone_at
- PWMAudio::Channel#stop
- PWMAudio::Channel#stop_at
- PWMAudio::Channel#volume=
- PWMAudio::Channel#pan=
- PWMAudio::Channel#mute=
- PWMAudio::Channel#cancel_scheduled
- PWMAudio::Channel#index
- Sources
- Scheduling Sound
- Constants
- Synth
Getting Started
Driving a Channel
Creating a Board::PWMAudio sets up the audio output.
It initializes the board’s audio pins, GPIO 24 and 25.
require "board/pwm_audio"
audio = Board::PWMAudio.new
# Play 440 Hz (A4) on channel 0
audio.tone(0, 440)
sleep 1
audio.stop(0)
audio.deinit
There are eight channels, numbered 0 to 7. Each plays its own sound, and sounds played at once are mixed together.
A = Board::PWMAudio
audio.beep(0, A::C4, 200) # play C for 200 ms
audio.beep(0, A::E4, 200)
audio.beep(0, A::G4, 200)
Assigning a Source to a Channel
audio.channel(0) returns the channel object, which takes a source and plays it.
ch = audio.channel(0)
ch.source = PWMAudio::Tone.new(440, waveform: PWMAudio::SINE)
ch.volume = 12
ch.play
sleep 1
ch.stop
There are three kinds of source: waveforms, samples, and streams. See Sources for the details.
Playing an Audio Sample
The drum sounds live as WAV files in /data/drums.
Read one, wrap it in a PWMAudio::Sample, and a channel plays it.
kick = PWMAudio::Sample.new(File.open("/data/drums/bd.wav", "r") { |f| f.read })
ch = audio.channel(3)
ch.source = kick
ch.play
A waveform plays until it is stopped, and a sample plays once and stops at its end.
Calling play again restarts it from the beginning.
Playing Sound
The methods on Board::PWMAudio take a channel number. Reach for these to play a sound quickly.
Board::PWMAudio#tone(channel, frequency, waveform:, volume:)
audio.tone(0, 440)
audio.tone(1, 880, waveform: Board::PWMAudio::SINE, volume: 10)
Plays a waveform at the given frequency in Hz on a channel (0 to 7). It keeps playing until it is stopped.
waveform takes one of the waveform constants:
SINE, SQUARE, TRIANGLE, or SAWTOOTH. It defaults to SQUARE.
volume is an integer from 0 (silent) to 15 (loudest). It defaults to 15.
Board::PWMAudio#beep(channel, frequency, duration_ms, waveform:, volume:)
audio.beep(0, 440, 200)
Plays a tone for duration_ms milliseconds and stops it.
Execution waits until the sound has finished.
Board::PWMAudio#stop(channel)
audio.stop(0)
Stops the channel, whichever source it plays. The level fades over a few milliseconds, so a stop never clicks.
Board::PWMAudio#stop_all
Stops every channel.
Board::PWMAudio#pan(channel, value)
audio.pan(0, 0) # left only
audio.pan(0, 8) # center
audio.pan(0, 15) # right only
Sets the stereo balance as an integer from 0 to 15: 0 is left, 8 is center, 15 is right. For a stereo sample it works by attenuating the opposite side.
Board::PWMAudio#mute(channel, flag)
audio.mute(0, true) # mute
audio.mute(0, false) # unmute
Mutes the channel. Its frequency, source, and other settings stay as they are, so unmuting picks up where it was. The change fades over a few milliseconds.
Board::PWMAudio#channel(index)
ch = audio.channel(3)
Returns the PWMAudio::Channel for a channel number (0 to 7). The same number always returns the same object.
Avoid driving one channel both by number and through its channel object. The same engine is behind both, so whichever call comes last wins.
Board::PWMAudio#load_sample(slot, data)
audio.load_sample(0, File.open("/data/drums/hh.wav", "r") { |f| f.read })
Loads a sample into a slot of the bank.
slot runs from 0 to 15 and data is WAV or QOA data.
A slot outside that range, or data that is neither format, raises ArgumentError.
Do not reload a slot while the sound in it is still playing.
Board::PWMAudio#sample_clock
now = audio.sample_clock
Returns the current playback position in samples. It counts up from the moment the audio started, by 50,000 per second, and is the time base for scheduling sound.
Board::PWMAudio#tone_at(at, channel, frequency, waveform:, volume:)
audio.tone_at(audio.sample_clock + 25_000, 0, 440) # start in 0.5 s
Schedules a waveform to start at playback position at.
waveform and volume work as they do in tone.
Returns true when the event was queued and false when the queue is full.
Board::PWMAudio#play_at(at, channel, volume, slot)
Schedules the source to play at playback position at.
Passing slot plays a sound from the bank
instead of the source assigned to the channel.
Like tone_at, it reports whether the event was queued.
Board::PWMAudio#stop_at(at, channel)
Schedules the channel to stop at playback position at.
Like tone_at, it reports whether the event was queued.
Board::PWMAudio#cancel_scheduled(channel)
Drops the events still pending on the channel. Call it before retriggering a note so a stale scheduled stop cannot cut it.
Board::PWMAudio#deinit
Stops the audio output and releases the hardware.
Using the Channel Object
Board::PWMAudio#channel(index) returns a PWMAudio::Channel.
Reach for it when a source is involved.
Apart from not taking a channel number, methods that share a name behave exactly as the ones under Playing Sound.
PWMAudio::Channel#source=(source)
ch.source = kick
Assigns a source to the channel. Nothing plays yet. A sample or a stream reaches the engine right away, a waveform when it is played.
PWMAudio::Channel#source
Returns the assigned source, or nil when there is none.
PWMAudio::Channel#play(volume:, slot:)
ch.play
ch.play(volume: 10)
ch.play(slot: 0)
Plays the assigned source. A waveform keeps playing until it is stopped, and a sample or a stream plays from the start and stops at its end. Calling it again while a sound plays restarts it from the beginning.
Without volume, the channel’s own volume is used.
Passing slot plays a sound from the bank
instead of the assigned source.
PWMAudio::Channel#play_at(at, volume:, slot:)
Schedules the source to play at playback position at.
PWMAudio::Channel#tone(frequency, waveform:, volume:)
ch.tone(440)
Assigns a waveform and plays it right away.
PWMAudio::Channel#tone_at(at, frequency, waveform:, volume:)
Schedules a waveform to start at playback position at.
PWMAudio::Channel#stop
Stops the sound.
PWMAudio::Channel#stop_at(at)
Schedules the channel to stop at playback position at.
PWMAudio::Channel#volume=(value)
ch.volume = 12
Sets the volume as an integer from 0 to 15. It defaults to 15.
This is the level play uses when it is not given one.
PWMAudio::Channel#pan=(value)
ch.pan = 8
Sets the stereo balance as an integer from 0 to 15: 0 is left, 8 is center, 15 is right.
PWMAudio::Channel#mute=(flag)
ch.mute = true
Mutes the channel. Its settings stay as they are.
PWMAudio::Channel#cancel_scheduled
Drops the events still pending on this channel.
PWMAudio::Channel#index
Returns the channel number.
Sources
There are three kinds of source. Each one is assigned to a channel and played from there.
PWMAudio::Tone
PWMAudio::Tone.new(440)
PWMAudio::Tone.new(440, waveform: PWMAudio::SINE)
Describes an oscillator. frequency and waveform read it back.
PWMAudio::Sample
PWMAudio::Sample.new(data)
Wraps 16-bit PCM WAV or QOA data, mono or stereo. The format is detected from the header.
samplerate, frames, and channels read back the details.
QOA is about one fifth the size of WAV, which helps when flash space is tight.
PWMAudio::Stream
song = audio.channel(7)
song.source = PWMAudio::Stream.new("/data/song.qoa")
song.play
Plays a file straight from flash, so a track too large for RAM plays fine.
It takes the same formats as PWMAudio::Sample.
Do not rewrite the file while it is playing. Writing moves its blocks and the sound breaks.
The Sample Bank
Preloading short sounds lets one channel play several of them.
audio.load_sample(0, File.open("/data/drums/hh.wav", "r") { |f| f.read })
audio.load_sample(1, File.open("/data/drums/oh.wav", "r") { |f| f.read })
ch = audio.channel(5)
ch.play(slot: 0)
Sounds sharing a channel cut each other off, which is what you want for an open and a closed hihat.
Scheduling Sound
When the timing has to be exact, on the beat for instance, schedule against the
playback position instead of waiting with sleep.
now = audio.sample_clock
audio.tone_at(now + 25_000, 0, 440) # start in 0.5 s
audio.stop_at(now + 50_000, 0) # stop in 1 s
sample_clock is the time base, a sample count that grows by 50,000 per second.
Add to it to get the position you want the sound at.
Four methods take a position. The channel object has the same four.
tone_at— play a waveformplay_at— play the sourcestop_at— stopcancel_scheduled— drop pending events
The queue holds 32 events, and a full queue returns false.
An event lands sample accurate when it is scheduled at least 2048 samples (about 41 ms) ahead. Anything closer is applied as soon as possible, which means slightly late.
Constants
| Constant | Value |
|---|---|
Board::PWMAudio::SAMPLE_RATE New in 2.0 |
50000 (samples per second) |
Board::PWMAudio::CHANNELS New in 2.0 |
8 (mixer channels) |
Board::PWMAudio::NUM_BANKS New in 2.0 |
16 (sample bank slots) |
Board::PWMAudio::SINE |
Sine wave |
Board::PWMAudio::SQUARE |
Square wave (default) |
Board::PWMAudio::TRIANGLE |
Triangle wave |
Board::PWMAudio::SAWTOOTH |
Sawtooth wave |
Note frequencies from C4 to C6 are defined as constants (C4, CS4, D4, … B5, C6).
S stands for sharp, so CS4 is C#4.
Synth
Synth builds sound itself from Ruby code.
Combine sine waves and noise, carve them with filters, and you get short sounds like drums.
What it returns is WAV data, which goes straight into a PWMAudio::Sample.
require "synth"
kick = PWMAudio::Sample.new(Synth.render(rate: 44100) {
sweep(0.28, from: 160, to: 44, curve: 28, decay: 12) +
noise(0.02, decay: 300).highpass(900) * 0.5
})
ch = audio.channel(0)
ch.source = kick
ch.play
Here a sine falling from 160 Hz to 44 Hz, the body of the kick, is mixed with a short burst of noise with the low end taken off, which is the attack of the beater.
Making a sound takes two steps. First make a waveform out of a sine
or some noise, then build the sound by carving and layering it.
Synth.render turns the result into WAV.
Synth.render(rate:, seed:)
data = Synth.render(rate: 44100) { noise(0.1, decay: 30) }
Renders the block and returns WAV data (16-bit mono).
The block has to return the built-up sound, a Synth::Buffer.
Anything else raises ArgumentError.
rate is the sample rate (samples per second), 44100 when omitted.
Harucom outputs at 50,000, but the engine resamples on playback, so any rate works.
rate: 50000 plays back with no conversion at all.
seed is the seed for the noise. The same seed always renders the same sound,
so one you like can be reproduced exactly.
Pass seed: RNG.random_int for a different noise take every time.
Before returning, normalize evens out the level and
fade_tail fades the end out. Both are applied for you.
The waveform math itself runs in C, but a single drum still takes tens of milliseconds. Render your sounds at startup rather than right before playing them.
Making Waveforms
These methods are available inside the block.
Each makes a waveform, a Synth::Buffer, for you to carve and layer.
sweep(seconds, from:, to:, curve:, decay:)
sweep(0.28, from: 160, to: 44, curve: 28, decay: 12) # kick
sweep(0.05, from: 1700, decay: 90) # rimshot
Makes seconds of a sine wave whose pitch falls.
Use it for sounds that drop in pitch the moment they are struck, like kicks and toms.
from is the starting frequency in Hz and to the ending one.
Omit to and the pitch stays at from.
curve is how fast the pitch falls, the larger the sooner it reaches to. It is 0 (no fall) when omitted.
decay is how fast the level decays. The larger it is, the shorter the sound.
noise(seconds, decay:)
noise(0.22, decay: 14)
Makes seconds of white noise, the raw material for snares, hihats, and claps.
decay is how fast the level decays, 0 (no decay) when omitted.
On its own it is a flat hiss, so carve the frequencies you do not want with
highpass or bandpass.
metallic(seconds, decay:, partials:)
metallic(0.09, decay: 46).bandpass(10000, q: 1.2).highpass(8000) # hihat
Stacks square waves at different frequencies into a metallic sound, the source of hihats and cymbals. Their overtones clash into gritty highs with no pitch to them.
partials is the array of square wave frequencies, Synth::HIHAT_PARTIALS
(204, 298, 366, 515, 540, and 800 Hz) when omitted.
Keep only the high frequencies, as above, and you have a hihat.
silence(seconds)
noise(0.02, decay: 300) + silence(0.5)
Makes seconds of silence.
A mix spans the longer of the two, so add silence to stretch the whole thing out.
Building the Sound
A waveform is a Synth::Buffer.
These operations layer and carve it into the sound you want.
Each returns a new Synth::Buffer, so they chain with ..
Synth::Buffer#+(other)
sweep(0.28, from: 160, to: 44, curve: 28, decay: 12) +
noise(0.02, decay: 300) * 0.5
Mixes two sounds. The result spans the longer of the two.
Synth::Buffer#*(value)
noise(0.02, decay: 300) * 0.5 # half the level
Scales the level. 1.0 leaves it alone, 0.5 halves it. Use it to balance the sounds you mix.
Synth::Buffer#highpass(cutoff)
noise(0.02, decay: 300).highpass(900)
Keeps what is above cutoff Hz and removes the low end.
Taking the low end off noise leaves the sharp attack.
Synth::Buffer#lowpass(cutoff)
noise(0.1, decay: 20).lowpass(400)
The other way around: keeps what is below cutoff Hz.
Synth::Buffer#bandpass(center, q:)
noise(0.28).bandpass(1100, q: 1.6)
Keeps the frequencies around center Hz.
q is the resonance, the larger the narrower the band and the more that frequency
stands out. It is 1.0 when omitted.
The crack of a clap comes out of that resonance.
Synth::Buffer#env(decay, at:, cut:, level:)
source = noise(0.28).bandpass(1100, q: 1.6)
source.env(220, cut: 0.009, level: 0.75) +
source.env(220, at: 0.009, cut: 0.009, level: 0.85) +
source.env(220, at: 0.018)
Applies an envelope to shape the level.
It is silent until at seconds, then decays at the rate of decay.
Pass cut and it sounds for cut seconds from at and stops there.
level scales the level. at defaults to 0 and level to 1.0, and nothing is cut when cut is omitted.
Summing several env of one source at different offsets builds a sound struck several
times, like a handclap. The example above is that shape.
Synth::Buffer#normalize(peak:)
buffer.normalize(peak: 0.9)
Scales the whole thing so its loudest point lands on peak, 0.9 when omitted.
Synth::Buffer#fade_tail(ms:)
buffer.fade_tail(ms: 4)
Fades the last ms milliseconds out so the sound ends exactly at zero, 4.0 when omitted.
Cutting off mid-signal would click.
Synth.renderappliesnormalizeandfade_tailfor you, so you rarely need to call them yourself.
The Drum Kit
The board’s drum sounds are already defined, and you render them by name.
require "synth"
require "synth/drum_kit"
snare = PWMAudio::Sample.new(Synth::DrumKit.render("sd"))
The names are bd (kick), sd (snare), hh (hihat), oh (open hihat),
cp (clap), lt (low tom), ht (high tom), and rim (rimshot).
The same sounds are stored as WAV files in /data/drums, which load faster than rendering them.
Synth::DrumKit.render(name, rate:, seed:)
Renders name and returns WAV data.
rate and seed work as they do in Synth.render.
A name that is not registered raises ArgumentError.
Synth::DrumKit.names
Synth::DrumKit.names #=> ["bd", "sd", "hh", "oh", "cp", "lt", "ht", "rim"]
Returns the registered names.
Synth::DrumKit.define(name)
Synth::DrumKit.define("kick2") do
sweep(0.4, from: 120, to: 38, curve: 20, decay: 8)
end
deep = PWMAudio::Sample.new(Synth::DrumKit.render("kick2"))
Registers a sound of your own under a name. The block is the same one Synth.render takes.
An existing name is overwritten.