dragonruby-audio

SKILL.md

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)
Weekly Installs
3
First Seen
7 days ago
Installed on
cline3
gemini-cli3
github-copilot3
codex3
kimi-cli3
cursor3