This skill covers advanced DragonRuby audio patterns. For basic sound playback see the main dragonruby skill.
Audio API Overview
Audio is controlled via args.audio (a persistent hash) and args.outputs.sounds (fire-and-forget).
# One-shot (auto-removed on completion)
args.outputs.sounds << 'sounds/coin.wav'
args.outputs.sounds << { path: 'sounds/hit.wav', gain: 0.5 }
# Managed (persists, looping or controlled)
args.audio[:music] = {
input: 'sounds/theme.ogg',
looping: true,
gain: 1.0,
pitch: 1.0, # 1.0 = normal speed
paused: false,
}
args.audio.delete(:music) # stop
# After loading:
args.audio[:music].playtime # seconds elapsed
args.audio[:music].playlength # total duration
args.audio.volume = 0.8 # global volume multiplier
Supported formats: .wav, .ogg. Resample to 44.1kHz max via ffmpeg.
Spatial Audio
Map screen coordinates to the normalised -1..1 range the engine expects:
def screen_to_audio_x(screen_x, screen_w = 1280)
(screen_x / screen_w.to_f * 2.0) - 1.0
end
def screen_to_audio_y(screen_y, screen_h = 720)
(screen_y / screen_h.to_f * 2.0) - 1.0
end
# Attach audio position to a moving entity:
args.audio[:explosion] = {
input: 'sounds/boom.wav',
x: screen_to_audio_x(entity.x),
y: screen_to_audio_y(entity.y),
z: 0.0,
gain: 1.0
}
# Update position each tick for a moving source:
if args.audio[:engine]
args.audio[:engine].x = screen_to_audio_x(ship.x)
args.audio[:engine].y = screen_to_audio_y(ship.y)
end
You can hang arbitrary metadata on the audio hash for convenience — extra keys are ignored by the engine:
args.audio[:sfx] = {
input: 'sounds/laser.wav',
gain: 1.0,
# custom metadata:
source_entity_id: enemy.entity_id,
started_at: Kernel.tick_count
}
Crossfading Between Tracks
Smoothly transition between two loops without clicks:
def tick_music(args)
args.state.main_track ||= :track_a
args.state.other_track ||= :track_b
args.audio[:track_a] ||= { input: 'sounds/music_a.ogg', looping: true, gain: 1.0 }
args.audio[:track_b] ||= { input: 'sounds/music_b.ogg', looping: true, gain: 0.0 }
# Trigger crossfade every 600 ticks (10 seconds):
if Kernel.tick_count.zmod?(600)
args.state.main_track, args.state.other_track =
args.state.other_track, args.state.main_track
end
# Fade toward targets:
target_a = args.state.main_track == :track_a ? 1.0 : 0.0
target_b = args.state.main_track == :track_b ? 1.0 : 0.0
args.audio[:track_a].gain = args.audio[:track_a].gain.lerp(target_a, 0.02)
args.audio[:track_b].gain = args.audio[:track_b].gain.lerp(target_b, 0.02)
end
Audio Queue with Timed Playback
Decouple scheduling from playback — queue audio for future ticks:
args.state.audio_queue ||= []
# Schedule a sound:
args.state.audio_queue << {
id: :"sfx_#{Kernel.tick_count}",
input: 'sounds/beep.wav',
gain: 0.8,
queue_at: Kernel.tick_count + 30 # play in 0.5 seconds
}
# Process queue each tick:
def process_audio_queue(args)
ready, pending = args.state.audio_queue.partition { |e| e[:queue_at] <= Kernel.tick_count }
args.state.audio_queue = pending
ready.each { |e| args.audio[e[:id]] = e }
end
Gain Decay (Natural Fade-out)
Apply a per-tick decay rate instead of manual envelope tracking:
args.audio.each do |id, track|
next unless track.is_a?(Hash) && track[:decay_rate]
track[:gain] -= track[:decay_rate]
args.audio.delete(id) if track[:gain] <= 0
end
# Create a decaying sound:
duration_ticks = 120
args.audio[:boom] = {
input: 'sounds/explosion.wav',
gain: 1.0,
decay_rate: 1.0 / duration_ticks
}
Procedural Synthesis
Generate audio from code using mathematical wave functions.
Sine wave
def sine_wave(frequency: 440, duration_ticks: 60, gain: 0.5)
sample_rate = 44100
num_samples = (sample_rate * duration_ticks / 60.0).ceil
samples = num_samples.map do |i|
gain * Math.sin(2.0 * Math::PI * frequency * i / sample_rate)
end
[2, sample_rate, samples] # [channels, sample_rate, data]
end
args.audio[:beep] = { input: sine_wave(frequency: 880, duration_ticks: 30) }
Caching by frequency
args.state.wave_cache ||= {}
args.state.wave_cache[440] ||= sine_wave(frequency: 440, duration_ticks: 60)
args.audio[:note_a] = { input: args.state.wave_cache[440] }
Harmonic decomposition (bell-like timbres)
def bell_sound(fundamental: 440)
harmonics = [
{ ratio: 0.5, gain: 1.0, duration: 120 },
{ ratio: 1.0, gain: 0.8, duration: 90 },
{ ratio: 2.0, gain: 0.5, duration: 60 },
{ ratio: 3.0, gain: 0.3, duration: 40 }
]
harmonics.map.with_index do |h, i|
id = :"bell_harmonic_#{Kernel.tick_count}_#{i}"
args.audio[id] = {
input: sine_wave(frequency: fundamental * h[:ratio],
duration_ticks: h[:duration],
gain: h[:gain]),
decay_rate: h[:gain] / h[:duration]
}
end
end
Interactive slider for real-time pitch/gain control
slider_rect = { x: 100, y: 300, w: 400, h: 20 }
if args.inputs.mouse.held && args.inputs.mouse.inside_rect?(slider_rect)
t = (args.inputs.mouse.x - slider_rect[:x]).to_f / (slider_rect[:w] - 1)
args.audio[:synth].pitch = 0.5 + t * 1.5 # 0.5x to 2.0x pitch
end
Beat Synchronisation
Accumulate fractional beats per tick to avoid rhythmic drift:
BPM = 120.0
args.state.beat_accumulator ||= 0.0
args.state.beats_per_tick ||= BPM / (60.0 * 60) # beats per tick at 60fps
args.state.beat_accumulator += args.state.beats_per_tick
current_beat = args.state.beat_accumulator.to_i
if current_beat != args.state.last_beat
args.state.last_beat = current_beat
# Fire on each beat:
args.outputs.sounds << 'sounds/kick.wav' if (current_beat % 4) == 0
args.outputs.sounds << 'sounds/snare.wav' if (current_beat % 4) == 2
end
Latency calibration
# Allow player to tap to calibrate audio latency
if args.inputs.keyboard.key_down.space
expected_beat_tick = args.state.quarter_beat_occurred_at
actual_tick = Kernel.tick_count
args.state.calibration_ticks += (actual_tick - expected_beat_tick) / 2
end
Note Mapping (Musical Scale)
A4 = 440.0
NOTE_OFFSETS = {
c: -9, d: -7, e: -5, f: -4, g: -2, a: 0, b: 2
}
def note_frequency(note, octave = 4)
semitones = NOTE_OFFSETS[note] + (octave - 4) * 12
A4 * (2.0 ** (semitones / 12.0))
end
# Usage:
freq = note_frequency(:c, 4) # => ~261.6 Hz (Middle C)
Waveform Visualisation
Render a waveform for debugging/aesthetics:
def render_waveform(args, samples, rect)
x_scale = rect[:w].to_f / samples.length
y_scale = rect[:h] / 2.0
cy = rect[:y] + rect[:h] / 2
args.outputs.static_lines << samples.map.with_index do |amp, i|
{ x: rect[:x] + i * x_scale, y: cy + amp * y_scale,
x2: rect[:x] + (i+1) * x_scale, y2: cy + (samples[i+1] || amp) * y_scale,
r: 0, g: 200, b: 255 }
end
end
Defaults Pattern for Audio Options
AUDIO_DEFAULTS = { gain: 1.0, pitch: 1.0, looping: false, fade_out: false }
def play_sound(args, id:, input:, **opts)
args.audio[id] = AUDIO_DEFAULTS.merge(opts).merge(input: input)
end
play_sound(args, id: :laser, input: 'sounds/laser.wav', gain: 0.6)