Skip to content

dfl/radspberry

Repository files navigation

radspberry

Version Ruby License: MIT Platform

A real-time audio DSP library for Ruby based on ffi-portaudio, designed to create synthesizers, apply filters, and generate audio in real-time with a simple, expressive API.

Features

  • Real-time output with ffi-portaudio
  • Native C extension for glitch-free audio
  • Output to speaker or wave file
  • DualRPMOscillator: Antialiased hard sync, through-zero FM, and PWM
  • Voice: High-level subtractive synth wrapper with flexible parts
  • MIDI input via portmidi ? (untested)
  • Refinements for scoped DSP extensions
  • 4x oversampling with anti-aliasing
  • Audio-rate filter modulation

Quick Start

require 'radspberry'
include DSP

# Play a note with Voice preset
voice = Voice.acid
Speaker.play(voice, volume: 0.4)

# Play :a2 for 0.5 seconds (blocks)
voice.play(:a2, 0.5)

Speaker.stop

See /examples for more. Notable scripts to run:

  • examples/synth_examples.rb: A comprehensive tour of the API, including patterns, arpeggiators, timing helpers, and modulation.
  • examples/naive_rpm_sync_demo.rb: An educational comparison demonstrating the audible difference between naive (aliasing) and proper (antialiased) oscillator hard sync.
  • examples/dual_rpm_showcase.rb: A deep dive into the advanced features of DualRPMOscillator, showcasing through-zero FM, PWM, and Kaiser window morphing.

Note Symbols

Use Ruby symbols for musical notes. Most methods expecting a frequency (like Voice#freq=) accept these directly.

:c4.freq           # => 261.63 Hz
:a4.midi           # => 69
:c4.major          # => [:c4, :e4, :g4]
:a3.minor7         # => [:a3, :c4, :e4, :g4]
:c4 + 7            # => :g4 (transpose up 7 semitones)

# Scales
:c3.scale(:major)              # => [:c3, :d3, :e3, :f3, :g3, :a3, :b3, :c4]
:c3.scale(:blues, octaves: 2)  # Two octaves of blues scale
:c3.scale(:dorian)             # Modal scales

Available scales: major, minor, harmonic_minor, melodic_minor, dorian, phrygian, lydian, mixolydian, locrian, pentatonic, minor_pentatonic, blues, chromatic, whole_tone.

Voice & Presets

Voice is a flexible subtractive synthesizer chain (Oscillator -> Filter -> Amplifier).

# Presets - optionally pass a note to start immediately
v = Voice.acid(:a2)    # TB-303 style acid bass
v = Voice.pad(:c3)     # Lush pad with slow attack
v = Voice.pluck(:e4)   # Plucky percussive sound
v = Voice.lead(:g4)    # Monophonic lead

# Control voices
v.play(:c4)            # Trigger note (on)
v.play(:c4, 0.5)       # Trigger note for 0.5 seconds
v.stop                 # Release note (off)

# Custom Voice Construction
# Pass classes (auto-instantiated) or specific instances
v = Voice.new(
  osc: DualRPMOscillator,
  filter: AudioRateSVF,
  amp_env: Env.adsr(attack: 0.1),
  filter_env: Env.perc
)

Parameter Control

# Parameter aliases for clean API
v.cutoff = 2000        # Filter base frequency
v.res = 0.8            # Filter resonance
v.attack = 0.01        # Amp envelope attack
v.decay = 0.2          # Amp envelope decay
v.sustain = 0.6        # Amp envelope sustain level
v.release = 0.3        # Amp envelope release

# Bulk parameter update
v.set(cutoff: 1500, res: 0.5, attack: 0.05)

DualRPMOscillator

The DualRPMOscillator is a feature-rich oscillator providing antialiased hard sync, PWM, and FM using Recursive Phase Modulation (RPM) and Kaiser windowing.

osc = DualRPMOscillator.new(:c3.freq)

# Antialiased Hard Sync & PWM
osc.sync_ratio = 2.5       # Sync frequency ratio
osc.window_alpha = 4.0     # Window smoothness (higher = smoother, less aliasing)
osc.duty = 0.5             # Pulse width (or phase offset for dual slaves)

# RPM Core Parameters
osc.beta = 1.5             # Recursive modulation amount (harmonic richness)
osc.morph = 0.5            # Morph between Saw-like (0.0) and Square-like (1.0)

# Modulation Example
# Modulate sync ratio for "laser" sweeps
lfo = Phasor.new(0.5)
osc_mod = osc.modulate(:sync_ratio, lfo, range: 1.0..8.0)

Envelope Presets

Env.perc                    # Quick percussive hit
Env.pluck                   # Plucked string decay
Env.pad                     # Slow pad envelope
Env.adsr(attack: 0.1, decay: 0.2, sustain: 0.6, release: 0.4)
Env.ad(attack: 0.01, decay: 0.5)

Timing Extensions

Time values default to seconds, but helpers exist for beat-based logic:

Clock.bpm = 140

sleep 1.beat      # Sleep for one beat (0.429s at 140 BPM)
sleep 0.5.beats   # Half beat
sleep 1.bar       # One bar (4 beats)
sleep 2.bars      # Two bars

Modulation DSL

Declaratively modulate any parameter with an LFO or other source:

filter = ButterLP.new(1000)
lfo = Phasor.new(5)  # 5Hz LFO

# Range-based modulation
filter = filter.modulate(:freq, lfo, range: 200..4000)

# Block-based (custom curve)
filter = filter.modulate(:q, lfo) { |v| 0.5 + v * 10 }

# Chain multiple modulations
filter = ButterLP.new(1000)
           .modulate(:freq, lfo1, range: 200..4000)
           .modulate(:q, lfo2, range: 0.5..10)

Speaker API

# Play any generator
Speaker.play(voice, volume: 0.4)

# Play with duration (stops automatically)
Speaker.play(voice, volume: 0.4, duration: 2.0)

# Stop playback
Speaker.stop

# Check status
Speaker.playing?   # => true/false

Installation

# Install portaudio
brew install portaudio  # macOS
# apt install portaudio19-dev  # Linux

# Clone the repository
git clone git@github.com:dfl/radspberry.git
cd radspberry

# Install dependencies
bundle install

# Build native extension (optional, for NativeSpeaker)
cd ext/radspberry_audio && ruby extconf.rb && make
mkdir -p ../../lib/radspberry_audio
cp radspberry_audio.bundle ../../lib/radspberry_audio/

# Run a demo
ruby examples/buffered_demo.rb

Audio Stability & the GVL

The default Speaker uses ffi-portaudio's callback mode, where PortAudio's native audio thread calls into Ruby. This requires acquiring Ruby's Global VM Lock (GVL), which means heavy Ruby work (computation, GC) can cause audio glitches.

NativeSpeaker (Glitch-Free Audio)

For stable audio during heavy Ruby work, use NativeSpeaker which uses a C extension:

NativeSpeaker.new(SuperSaw.new(110), volume: 0.3)

# Audio stays smooth even during heavy computation
10.times { heavy_computation }

NativeSpeaker.stop

The C callback reads from a lock-free ring buffer without touching Ruby or the GVL. A Ruby producer thread fills the buffer in the background.

Build the extension:

cd ext/radspberry_audio
ruby extconf.rb
make
cp radspberry_audio.bundle ../../lib/radspberry_audio/

Audio-Rate SVF Filter

The AudioRateSVF is a TPT/Cytomic-style state variable filter optimized for audio-rate modulation:

# Create filter with saturation
svf = AudioRateSVF.new(freq: 1000, q: 4.0, kind: :low, drive: 12.0)

# Audio-rate frequency modulation (efficient - uses fast tan approximation)
lfo = Phasor.new(5)  # 5Hz LFO
noise = Noise.new

loop do
  mod_freq = 500 + lfo.tick * 2000  # Modulate 500-2500Hz
  output = svf.tick_with_mod(noise.tick, mod_freq)
end

# Filter modes: :low, :band, :high, :notch
# 4-pole mode (24dB/oct):
svf.four_pole = true

4x Oversampling

Oversampling reduces aliasing from nonlinear processing (saturation, distortion). Uses a 12th-order elliptic anti-aliasing filter.

Wrap a single processor

svf = AudioRateSVF.new(freq: 2000, drive: 18.0)
oversampled = DSP.oversample(svf)
chain = noise >> oversampled

Run entire chain at 4x (recommended)

chain = DSP.oversampled do
  osc = Phasor.new(440)
  filter = AudioRateSVF.new(freq: 2000, drive: 12.0)
  osc >> filter
end

# Everything inside runs at 176.4kHz, decimated to 44.1kHz at output
chain.to_wav(2, filename: "smooth_saturation.wav")

The chain approach is cleaner because all components use the correct sample rate automatically, and there's only one decimation point at output.

Refinements (Scoped Extensions)

By default, radspberry adds helper methods globally to Array, Vector, and Module. If you prefer explicit, lexically-scoped extensions, use refinements instead:

require 'radspberry'
using DSP::Refinements

# These methods only work in this file:
[1, 2, 3].to_v                    # => Vector[1, 2, 3]
Vector.zeros(4)                   # => Vector[0.0, 0.0, 0.0, 0.0]
[osc1, osc2].tick_sum             # Sum tick values from array of oscillators

Refinements are activated with using and are scoped to the current file—they won't leak into other parts of your codebase.

Requirements

  • Ruby >= 2.7
  • portaudio library
  • ffi-portaudio gem

Documentation

Future Ideas

Some directions worth exploring:

Fibers for envelopes/sequencers

Use Ruby's lightweight coroutines for stateful, event-driven generators:

env = Fiber.new do
  100.times { |i| Fiber.yield(i / 100.0) }  # attack
  loop { Fiber.yield(1.0) }                  # sustain until released
end

Signal graphs as data

Treat chains as inspectable/optimizable structures before execution:

graph = osc >> gain(0.5) >> gain(0.8) >> filter
graph.optimize!  # => osc >> gain(0.4) >> filter
graph.compile!   # => generate specialized native code

Parallel offline rendering with Ractors

For bounce-to-disk, split work across cores:

samples = synth.parallel_render(seconds: 60, cores: 8)

License

MIT - Copyright (c) 2012-2025 David Lowenfels

About

real-time audio synthesis library for ruby

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published