diff --git a/examples/piano.cr b/examples/piano.cr index 0631362..2f2b18d 100644 --- a/examples/piano.cr +++ b/examples/piano.cr @@ -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 diff --git a/src/audio.cr b/src/audio.cr index b23be68..3ea047e 100644 --- a/src/audio.cr +++ b/src/audio.cr @@ -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, diff --git a/src/audio/echo_effect.cr b/src/audio/echo_effect.cr new file mode 100644 index 0000000..67fab2d --- /dev/null +++ b/src/audio/echo_effect.cr @@ -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 diff --git a/src/audio/instrument.cr b/src/audio/instrument.cr index e8d4aa9..43f430d 100644 --- a/src/audio/instrument.cr +++ b/src/audio/instrument.cr @@ -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 diff --git a/src/audio/low_pass_filter.cr b/src/audio/low_pass_filter.cr new file mode 100644 index 0000000..9c5468b --- /dev/null +++ b/src/audio/low_pass_filter.cr @@ -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