mirror of
https://github.com/SleepingInsomniac/pixelfaucet
synced 2024-11-17 07:48:20 +01:00
Add audio synthesis
This commit is contained in:
parent
c43dfbd90e
commit
bddc1fe211
6 changed files with 428 additions and 0 deletions
172
examples/piano.cr
Normal file
172
examples/piano.cr
Normal file
|
@ -0,0 +1,172 @@
|
|||
require "../src/game"
|
||||
require "../src/controller"
|
||||
require "../src/pixel_text"
|
||||
require "../src/audio"
|
||||
require "../src/audio/*"
|
||||
|
||||
module PF
|
||||
enum Instrument : UInt8
|
||||
RetroVoice
|
||||
PianoVoice
|
||||
end
|
||||
|
||||
class RetroVoice < Voice
|
||||
def hertz(time)
|
||||
envelope.amplitude(time) * (
|
||||
Oscilator.triangle(note.hertz, time)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
class PianoVoice < Voice
|
||||
def initialize(@note, time)
|
||||
@envelope = Envelope.new(time,
|
||||
attack_time: 0.01,
|
||||
decay_time: 1.0,
|
||||
sustain_level: 0.0,
|
||||
release_time: 0.1,
|
||||
releasable: false
|
||||
)
|
||||
end
|
||||
|
||||
def hertz(time)
|
||||
envelope.amplitude(time) * (
|
||||
Oscilator.sin(note.hertz, time)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
class Piano < Game
|
||||
@text : PF::PixelText = PF::PixelText.new("assets/pf-font.png")
|
||||
@key_size : Int32
|
||||
@key_width : Int32
|
||||
@middle : Int32
|
||||
@keys : UInt32 = 15
|
||||
@base_octave : UInt8 = 4u8
|
||||
@accidentals : StaticArray(UInt8, 12) = StaticArray[0u8, 1u8, 0u8, 0u8, 1u8, 0u8, 1u8, 0u8, 0u8, 1u8, 0u8, 1u8]
|
||||
# @highlight : Pixel = Pixel.new(0, 127, 255)
|
||||
@highlight : Pixel = Pixel.new(120, 120, 120)
|
||||
@instrument : Instrument = Instrument::RetroVoice
|
||||
|
||||
def initialize(*args, **kwargs)
|
||||
super
|
||||
|
||||
@key_size = height // 2 - 25
|
||||
@key_width = width // 10
|
||||
@middle = (height // 2) + 25
|
||||
|
||||
@text.color(Pixel.new(127, 127, 127))
|
||||
@controller = PF::Controller(Keys).new({
|
||||
Keys::UP => "up",
|
||||
Keys::DOWN => "down",
|
||||
Keys::KEY_1 => "1",
|
||||
Keys::KEY_2 => "2",
|
||||
Keys::Z => "A",
|
||||
Keys::S => "AS",
|
||||
Keys::X => "B",
|
||||
Keys::C => "C",
|
||||
Keys::F => "CS",
|
||||
Keys::V => "D",
|
||||
Keys::G => "DS",
|
||||
Keys::B => "E",
|
||||
Keys::N => "F",
|
||||
Keys::J => "FS",
|
||||
Keys::M => "G",
|
||||
Keys::K => "GS",
|
||||
Keys::COMMA => "A+",
|
||||
Keys::L => "AS+",
|
||||
Keys::PERIOD => "B+",
|
||||
Keys::SLASH => "C+",
|
||||
})
|
||||
|
||||
@sounds = [] of Voice
|
||||
@keysdown = {} of String => Voice
|
||||
|
||||
@audio = Audio.new(channels: 1) do |time, channel|
|
||||
@sounds.reduce(0.0) do |total, sound|
|
||||
total + sound.hertz(time)
|
||||
end
|
||||
end
|
||||
|
||||
@audio.play
|
||||
|
||||
@white_keys = [] of Tuple(Vector2(Int32), Vector2(Int32), String)
|
||||
@black_keys = [] of Tuple(Vector2(Int32), Vector2(Int32), String)
|
||||
|
||||
pos = 0
|
||||
(Note::NOTES + %w[A+ AS+ B+ C+]).map_with_index do |name, i|
|
||||
if @accidentals[i % 12] == 0
|
||||
top_left = Vector[@key_width * pos, @middle - @key_size]
|
||||
bottom_right = Vector[(@key_width * pos) + @key_width, @middle + @key_size]
|
||||
@white_keys << {top_left, bottom_right, name}
|
||||
pos += 1
|
||||
else
|
||||
shrinkage = (@key_width // 8)
|
||||
left = (@key_width * pos) - (@key_width // 2) + shrinkage
|
||||
top_left = Vector[left, @middle - @key_size]
|
||||
bottom_right = Vector[left + @key_width - (shrinkage * 2), @middle]
|
||||
@black_keys << {top_left, bottom_right, name}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update(dt, event)
|
||||
@controller.map_event(event)
|
||||
|
||||
@base_octave += 1 if @controller.pressed?("up")
|
||||
@base_octave -= 1 if @controller.pressed?("down")
|
||||
@instrument = Instrument::RetroVoice if @controller.pressed?("1")
|
||||
@instrument = Instrument::PianoVoice if @controller.pressed?("2")
|
||||
|
||||
{% for name, n in Note::NOTES + %w[A+ AS+ B+ C+] %}
|
||||
if @controller.pressed?({{name}})
|
||||
voice = case @instrument
|
||||
when Instrument::RetroVoice
|
||||
RetroVoice.new(Note.new({{n}}_u8, @base_octave), @audio.time)
|
||||
when Instrument::PianoVoice
|
||||
PianoVoice.new(Note.new({{n}}_u8, @base_octave), @audio.time)
|
||||
else
|
||||
PianoVoice.new(Note.new({{n}}_u8, @base_octave), @audio.time)
|
||||
end
|
||||
@keysdown[{{name}}] = voice
|
||||
@sounds << voice
|
||||
end
|
||||
|
||||
if @controller.released?({{name}})
|
||||
@keysdown[{{name}}].release(@audio.time)
|
||||
@keysdown.delete({{name}})
|
||||
end
|
||||
{% end %}
|
||||
end
|
||||
|
||||
def draw
|
||||
clear
|
||||
|
||||
text = <<-TEXT
|
||||
Press up/down to change octave, Bottom row of keyboard plays notes
|
||||
1 : RetroVoice, 2 : PianoVoice
|
||||
Octave: #{@base_octave}, Voice : #{@instrument}
|
||||
TEXT
|
||||
@text.draw_to(screen, text, 5, 5)
|
||||
|
||||
@white_keys.each do |key|
|
||||
top_left, bottom_right, name = key
|
||||
fill_rect(top_left, bottom_right, @keysdown[name]? ? @highlight : Pixel.white)
|
||||
draw_rect(top_left, bottom_right, Pixel.new(127, 127, 127))
|
||||
@text.draw_to(screen, name, top_left.x + 2, top_left.y + (@key_size * 2) - @text.char_height - 2)
|
||||
end
|
||||
|
||||
@black_keys.each do |key|
|
||||
top_left, bottom_right, name = key
|
||||
fill_rect(top_left, bottom_right, @keysdown[name]? ? @highlight : Pixel.black)
|
||||
draw_rect(top_left, bottom_right, Pixel.new(127, 127, 127))
|
||||
@text.draw_to(screen, name, top_left.x + 2, top_left.y + @key_size - @text.char_height - 2)
|
||||
end
|
||||
|
||||
fill_rect(0, @middle - @key_size - 2, width, @middle - @key_size, Pixel.new(200, 20, 20))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
game = PF::Piano.new(500, 200, 2)
|
||||
game.run!
|
83
src/audio.cr
Normal file
83
src/audio.cr
Normal file
|
@ -0,0 +1,83 @@
|
|||
module PF
|
||||
class Audio
|
||||
@@box : Pointer(Void)?
|
||||
@spec : LibSDL::AudioSpec
|
||||
@device_id : LibSDL::AudioDeviceID
|
||||
property volume = 0.5
|
||||
property clipping_scaler = 0.5
|
||||
delegate :freq, to: @spec
|
||||
@playing : Bool = false
|
||||
getter time : Float64 = 0.0
|
||||
@channel : UInt8 = 0u8
|
||||
|
||||
def initialize(freq : Int32 = 44100, channels : UInt8 = 2, samples : UInt16 = 512, &callback : Float64, UInt8 -> Float64)
|
||||
boxed_data = Box.box({
|
||||
callback,
|
||||
(1 / freq) / channels, # the time per sample
|
||||
pointerof(@volume),
|
||||
pointerof(@time),
|
||||
pointerof(@channel),
|
||||
channels,
|
||||
pointerof(@clipping_scaler),
|
||||
})
|
||||
@@box = boxed_data
|
||||
|
||||
@spec = LibSDL::AudioSpec.new(
|
||||
freq: freq,
|
||||
format: LibSDL::AUDIO_S16SYS,
|
||||
channels: channels,
|
||||
samples: samples,
|
||||
callback: ->(userdata : Void*, stream : UInt8*, len : Int32) {
|
||||
# return if len == 0
|
||||
# Convert the stream into the correct type
|
||||
stream = stream.as(Pointer(Int16))
|
||||
# Calculate the correct length in size of Int16 (according to audio spec AUDIO_S16SYS)
|
||||
length = len // (sizeof(Int16) // sizeof(UInt8))
|
||||
# Unbox the user callback
|
||||
unboxed_data = Box(Tuple(typeof(callback), Float64, Float64*, Float64*, UInt8*, UInt8, Float64*)).unbox(userdata)
|
||||
user_callback, time_step, volume, time, channel, channel_count, clipping_scaler = unboxed_data
|
||||
# Iterate over the size of the buffer
|
||||
0.upto(length - 1) do |x|
|
||||
# Call the user callback and recieve the sample
|
||||
sample = user_callback.call(time.value, channel.value)
|
||||
channel.value = (channel.value + 1) % channel_count
|
||||
# Increment the time
|
||||
time.value += time_step
|
||||
# Fill the buffer location with the sample
|
||||
# Make sure to convert the Float64 into a signed Int16 for compatability with the audio format
|
||||
(stream + x).value = (sample * Int16::MAX * volume.value * clipping_scaler.value).clamp(Int16::MIN, Int16::MAX).to_i16
|
||||
end
|
||||
},
|
||||
userdata: boxed_data
|
||||
)
|
||||
|
||||
@device_id = LibSDL.open_audio_device(nil, 0, pointerof(@spec), pointerof(@spec), 0)
|
||||
end
|
||||
|
||||
def playing?
|
||||
@playing
|
||||
end
|
||||
|
||||
def play
|
||||
LibSDL.pause_audio_device(@device_id, 0)
|
||||
@playing = true
|
||||
end
|
||||
|
||||
def pause
|
||||
LibSDL.pause_audio_device(@device_id, 1)
|
||||
@playing = false
|
||||
end
|
||||
|
||||
def queue(sample : Int16)
|
||||
queue(pointerof(sample), 1)
|
||||
end
|
||||
|
||||
def queue(sample : Int16*, length = 1)
|
||||
LibSDL.queue_audio(@device_id, sample, sizeof(typeof(sample)) * length)
|
||||
end
|
||||
|
||||
def finalize
|
||||
LibSDL.close_audio(@device_id)
|
||||
end
|
||||
end
|
||||
end
|
67
src/audio/envelope.cr
Normal file
67
src/audio/envelope.cr
Normal file
|
@ -0,0 +1,67 @@
|
|||
module PF
|
||||
struct Envelope
|
||||
@attack_time : Float64 = 0.05
|
||||
@decay_time : Float64 = 0.1
|
||||
@sustain_level : Float64 = 0.8
|
||||
@release_time : Float64 = 0.5
|
||||
@initial_level : Float64 = 1.0
|
||||
@releasable : Bool = true
|
||||
|
||||
@started_at : Float64 = 0.0
|
||||
@released_at : Float64? = nil
|
||||
@released : Bool = false
|
||||
|
||||
def initialize(@started_at : Float64, @attack_time = 0.05, @decay_time = 0.1, @sustain_level = 0.8, @release_time = 0.5, @releasable = true)
|
||||
end
|
||||
|
||||
# def finished?(time)
|
||||
# duration =
|
||||
# if @released
|
||||
# @attack_time + @decay_time
|
||||
# @sustain_time.try { |st| duration += st }
|
||||
# end
|
||||
|
||||
def amplitude(time : Float64)
|
||||
amp = 0.0
|
||||
|
||||
if released_at = @released_at
|
||||
duration = time - released_at
|
||||
|
||||
if duration <= @release_time
|
||||
# Release phase
|
||||
amp = ((duration / @release_time) * (-@sustain_level)) + @sustain_level
|
||||
else
|
||||
amp = 0.0
|
||||
end
|
||||
else
|
||||
duration = time - @started_at
|
||||
|
||||
if duration <= @attack_time
|
||||
# Attack phase
|
||||
amp = (duration / @attack_time) * @initial_level
|
||||
elsif duration > @attack_time && duration <= (@attack_time + @decay_time)
|
||||
# Decay phase
|
||||
amp = ((duration - @attack_time) / @decay_time) * (@sustain_level - @initial_level) + @initial_level
|
||||
else
|
||||
# Sustain phase
|
||||
amp = @sustain_level
|
||||
end
|
||||
end
|
||||
|
||||
amp < 0.0001 ? 0.0 : amp
|
||||
end
|
||||
|
||||
def held?
|
||||
@released_at.nil?
|
||||
end
|
||||
|
||||
def released?
|
||||
!@released_at.nil?
|
||||
end
|
||||
|
||||
def release(time : Float64)
|
||||
return unless @releasable
|
||||
@released_at = time
|
||||
end
|
||||
end
|
||||
end
|
45
src/audio/note.cr
Normal file
45
src/audio/note.cr
Normal file
|
@ -0,0 +1,45 @@
|
|||
module PF
|
||||
struct Note
|
||||
TWELFTH_ROOT = 2 ** (1 / 12)
|
||||
NOTES = %w[A AS B C CS D DS E F FS G GS]
|
||||
|
||||
property note : UInt8 = 0
|
||||
property octave : UInt8 = 4
|
||||
|
||||
def initialize
|
||||
end
|
||||
|
||||
def initialize(@note, @octave = 4u8)
|
||||
end
|
||||
|
||||
def name
|
||||
NOTES[@note % 12]
|
||||
end
|
||||
|
||||
def base_hertz
|
||||
27.5 * (2 ** @octave)
|
||||
end
|
||||
|
||||
def hertz
|
||||
base_hertz * (TWELFTH_ROOT ** @note)
|
||||
end
|
||||
|
||||
def +(value : UInt8)
|
||||
Note.new(@note + value, @octave)
|
||||
end
|
||||
|
||||
def -(value : UInt8)
|
||||
Note.new(@note - value, @octave)
|
||||
end
|
||||
|
||||
# # Decabells to volume
|
||||
# def db_to_volume(db : Float64)
|
||||
# 10.0 ** (0.05 * db)
|
||||
# end
|
||||
#
|
||||
# # Volume to decabells
|
||||
# def volume_to_db(volume : Float64)
|
||||
# 20.0 * Math.log10f(volume)
|
||||
# end
|
||||
end
|
||||
end
|
48
src/audio/oscillator.cr
Normal file
48
src/audio/oscillator.cr
Normal file
|
@ -0,0 +1,48 @@
|
|||
module PF
|
||||
module Oscilator
|
||||
TWELFTH_ROOT = 2 ** (1 / 12)
|
||||
|
||||
# Calculate a sine wave ~~~~~~~~
|
||||
def self.sin(hertz : Float64, time : Float64)
|
||||
Math.sin(av(hertz) * time)
|
||||
end
|
||||
|
||||
# Calculate a square wave _|-|_|-|_
|
||||
def self.square(hertz : Float64, time : Float64)
|
||||
Math.sin(av(hertz) * time) > 0 ? 1.0 : 0.0
|
||||
end
|
||||
|
||||
# Calculate a triangle wave /\/\/\/\/
|
||||
def self.triangle(hertz : Float64, time : Float64)
|
||||
Math.asin(Math.sin(av(hertz) * time)) * 2.0 / Math::PI
|
||||
end
|
||||
|
||||
# Calculate a sawtooth wave by addition of sine waves
|
||||
# the more *sins* specified, the closer the waveform will
|
||||
# match a straight sawtooth wave
|
||||
# /|/|/|/|
|
||||
def self.saw(hertz : Float64, time : Float64, sins : Int)
|
||||
value = 0.0
|
||||
n = 0.0
|
||||
while (n += 1.0) < sins
|
||||
value += (Math.sin(n * av(hertz) * time)) / n
|
||||
end
|
||||
value * (2.0 / Math::PI)
|
||||
end
|
||||
|
||||
# Calculate a sawtooth wave
|
||||
def self.saw(hertz : Float64, time : Float64)
|
||||
(2.0 / Math::PI) * (hertz * Math::PI * (time % (1.0 / hertz)) - (Math::PI / 2.0))
|
||||
end
|
||||
|
||||
# Produces static noise
|
||||
def self.noise(hertz : Float64, time : Float64)
|
||||
rand(-1.0..1.0)
|
||||
end
|
||||
|
||||
# Convert hertz into angular velocity
|
||||
def self.av(hertz)
|
||||
2.0 * Math::PI * hertz
|
||||
end
|
||||
end
|
||||
end
|
13
src/audio/voice.cr
Normal file
13
src/audio/voice.cr
Normal file
|
@ -0,0 +1,13 @@
|
|||
module PF
|
||||
abstract class Voice
|
||||
delegate :start, :release, :held?, :released?, to: @envelope
|
||||
property envelope : Envelope
|
||||
property note : Note
|
||||
|
||||
def initialize(@note : Note, time : Float64)
|
||||
@envelope = Envelope.new(time)
|
||||
end
|
||||
|
||||
abstract def hertz(time : Float64)
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue