Add new audio effects

This commit is contained in:
Alex Clink 2023-03-26 14:20:18 -04:00
parent 44935fcbcc
commit 172478cffa
5 changed files with 133 additions and 18 deletions

View file

@ -18,13 +18,14 @@ module PF
@white_keys = [] of Tuple(Vector2(Int32), Vector2(Int32), String)
@black_keys = [] of Tuple(Vector2(Int32), Vector2(Int32), String)
@instruments : Array(Instrument) = [RetroVoice.new, PianoVoice.new, Flute.new, KickDrum.new, SnareDrum.new, Harmonica.new]
@instruments : Array(Instrument) = [RetroVoice.new, SineVoice.new, PianoVoice.new, Flute.new, KickDrum.new, SnareDrum.new, Harmonica.new]
def initialize(*args, **kwargs)
super
@text_color = Pixel.new(127, 127, 127)
@controller = PF::Controller(Keys).new({
Keys::E => "echo",
Keys::UP => "octave up",
Keys::DOWN => "octave down",
Keys::LEFT => "prev inst",
@ -52,18 +53,27 @@ module PF
@sounds = [] of Sound
@keysdown = {} of String => Tuple(Instrument, UInt32)
@echo = false
echo_effect = EchoEffect.new(44100 // 3)
# Initialize an audio handler
# - the given Proc will be called at the sample rate/freq param (44.1Khz is standard)
# - the channel variable describes which speaker the sample is for
@audio = Audio.new(channels: 1) do |time, channel|
value = 0.0
@instruments.each do |instrument|
instrument.sounds.each do |sound|
value += sound.sample(time)
end
end
value
if @echo
echo_effect.apply(value)
else
value
end
end
@key_size = height // 2 - 25
@ -115,6 +125,10 @@ module PF
@base_note += 12 if @controller.pressed?("octave up") && @base_note <= 112
@base_note -= 12 if @controller.pressed?("octave down") && @base_note >= 21 + 12
if @controller.pressed?("echo")
@echo = !@echo
end
if @controller.pressed?("next inst")
@instrument = (@instrument + 1) % @instruments.size
end
@ -134,9 +148,11 @@ module PF
end
if @controller.released?(name)
instrument, note_id = @keysdown[name]
instrument.off(note_id, @audio.time)
@keysdown.delete(name)
if tuple = @keysdown.[name]?
instrument, note_id = tuple
instrument.off(note_id, @audio.time)
@keysdown.delete(name)
end
end
end
end
@ -147,9 +163,9 @@ module PF
draw_string(<<-TEXT, 5, 5, @text_color)
Press up/down to change octave, Bottom row of keyboard plays notes
#{@instruments.map(&.name).join(", ")}
Octave: #{@base_note // 12 - 1}, Voice : #{@instruments[@instrument].name}
Octave: #{@base_note // 12 - 1}, Voice: #{@instruments[@instrument].name}, Echo: #{@echo ? "on" : "off"}
#{@instruments[@instrument].sounds.map { |s| s.hertz.round(2) }}
TEXT
TEXT
@white_keys.each do |key|
top_left, bottom_right, name = key

View file

@ -1,5 +1,6 @@
module PF
class Audio
alias Callback = Float64, UInt8 -> Float64
# stored as a class variable to avoid garbage collection, since it's passed to a C function
@@box : Pointer(Void)?
@spec : LibSDL::AudioSpec
@ -12,7 +13,7 @@ module PF
getter time : Float64 = 0.0
@channel : UInt8 = 0u8
def initialize(freq : Int32 = 44100, channels : UInt8 = 2, samples : UInt16 = 512, &callback : Float64, UInt8 -> Float64)
def initialize(freq : Int32 = 44100, channels : UInt8 = 2, samples : UInt16 = 512, &callback : Callback)
# Information to be passed to the audio callback
boxed_data = Box.box({
callback,

24
src/audio/echo_effect.cr Normal file
View file

@ -0,0 +1,24 @@
struct EchoEffect
@cursor : Int32 = 0
def initialize(frames : Int32)
@buffer = Slice(Float64).new(frames, 0.0)
@filter = LowPassFilter.new(440.0, 0.7, 44100)
end
def read
@buffer[@cursor]
end
def write(sample : Float64)
@buffer[@cursor] = sample
@cursor += 1
@cursor = 0 if @cursor >= @buffer.size
end
def apply(sample : Float64, strength = 0.5)
sample += @filter.apply(read * strength)
write(sample)
sample
end
end

View file

@ -41,7 +41,20 @@ module PF
sustain: Envelope::Stage.new(Float64::INFINITY, 0.5, 0.5),
release: Envelope::Stage.new(0.5, 1.0, 0.0)
)
@wave = Sound.saw_wave(7.0, 0.001)
@wave = Sound.saw_wave(10.0, 0.005)
end
end
class SineVoice < Instrument
def initialize
@name = "Sine"
@envelope = Envelope.new(
attack: Envelope::Stage.new(0.01, 0.0, 1.0),
decay: Envelope::Stage.new(0.1, 1.0, 0.5),
sustain: Envelope::Stage.new(Float64::INFINITY, 0.5, 0.5),
release: Envelope::Stage.new(0.5, 1.0, 0.0)
)
@wave = Sound.sin_wave(5.0, 0.001)
end
end
@ -62,17 +75,25 @@ module PF
release: Envelope::Stage.new(0.3, 1.0, 0.0)
)
harmonics = [
{1.0, 0.0},
{0.5, 0.0},
{0.25, 0.0},
]
@wave = ->(time : Float64, hertz : Float64) do
# https://www.desmos.com/calculator/mnxargxllk
av = 2 * Math::PI * hertz * time
y = (Math.sin(Math::PI * (av / Math::PI)) ** 3) + Math.sin(Math::PI * ((av / Math::PI) + (2 / 3)))
y = (Math.sin(av) ** 3) + Math.sin(av + 0.6666)
y += y / 2
y += y / 4
y += y / 8
y += y / 16
y += y / 32
y /= 5
y = 0.0
harmonics.each do |amplitude, phase|
av = 2 * Math::PI * (hertz / 2.0) * time + phase
y += amplitude * (
(Math.sin(Math::PI * (av / Math::PI)) ** 3) +
(Math.sin(Math::PI * ((av / Math::PI) + (2 / 3))))
)
end
y
end
end
end

View file

@ -0,0 +1,53 @@
# first-order low-pass filter
class LowPassFilter
@x1 : Float64
@x2 : Float64
@y1 : Float64
@y2 : Float64
@b0 : Float64
@b1 : Float64
@b2 : Float64
@a1 : Float64
@a2 : Float64
def initialize(cutoff_frequency : Float64, quality_factor : Float64, sample_rate : Float64)
# Compute the filter coefficients
omega = 2.0 * Math::PI * cutoff_frequency / sample_rate
sin_omega = Math.sin(omega)
cos_omega = Math.cos(omega)
alpha = sin_omega / (2.0 * quality_factor)
b0 = (1.0 - cos_omega) / 2.0
b1 = 1.0 - cos_omega
b2 = b0
a0 = 1.0 + alpha
a1 = -2.0 * cos_omega
a2 = 1.0 - alpha
# Initialize the filter state
@x1 = 0.0
@x2 = 0.0
@y1 = 0.0
@y2 = 0.0
# Store the filter coefficients
@b0 = b0 / a0
@b1 = b1 / a0
@b2 = b2 / a0
@a1 = a1 / a0
@a2 = a2 / a0
end
def apply(sample : Float64) : Float64
# Apply the filter to the sample
output_sample = @b0 * sample + @b1 * @x1 + @b2 * @x2 - @a1 * @y1 - @a2 * @y2
# Update the filter state variables
@x2 = @x1
@x1 = sample
@y2 = @y1
@y1 = output_sample
output_sample
end
end