$:.unshift(File.join(File.dirname(__FILE__), 'lib'))
require 'fmod'
# Undefine the old Audio module so there is no interference.
if defined? Audio
Object.send(:remove_const, :Audio)
end
##
# Complete redefinition of the Audio module with additional capabilities.
#
# @note <b>Notes on Pitch</b>
#
# By default, pitch is changed by simply altering the speed of the sound. If
# you wish to accomplish true pitch shifting, use the PitchShifter DSP unit,
# which alters pitch without changing playback speed.
# @author Eric "ForeverZer0" Freed
module Audio
##
# Only change if you have a project that renames the "Game.exe" and "Game.ini"
EXE = 'Game'
##
# File extensions that will be included for searching audio paths.
# For performance reasons, only include extensions that your project uses.
EXTENSIONS = %w[.mp3 .ogg .mid .wma]
##
# Channel names, as symbols. Removing the default names listed here will break
# backwards compatibility.
CHANNELS = [:BGM, :BGS, :ME, :SE].freeze
include FMOD::Enums
include FMOD::Effects
##
# Retrieves a hash using the symbols located in {CHANNELS} mapping to each
# channel handle.
# @return [Hash<Symbol, FMOD::Channel>] The {FMOD::Channel} instances.
def self.channels
@channels
end
##
# Begins playback on the specified channel.
#
# File extensions may be omitted. All paths should be relative to the base
# project directory. RTP directories will also be searched if file is not
# found locally.
# @param channel [Symbol] The name of the channel being played, a value
# defined in the {CHANNELS} array.
# @param filename [String] Relative filename to play. File extensions may be
# omitted.
# @param volume [Integer] Volume level. By default this within range of +0+
# to +100+, but FMOD supports amplification and higher values.
# @param pitch [Integer] Pitch level. +50+ to +200+.
# @param looping [Boolean] +true+ to have sound loop; otherwise +false+.
# @param effects [FMOD::DSP] Optional DSP effects to apply to the sound. This
# is more efficient than adding later.
# @return [FMOD::Channel] The channel instance that is playing the sound.
def self.play(channel, filename, volume, pitch, looping, *effects)
if channel_playing?(channel, filename)
@channels[channel].volume = volume * 0.01
@channels[channel].pitch = pitch * 0.01
return
end
@sounds[channel].release unless @sounds[channel].nil?
mode = looping ? MODE[:loop_normal] : MODE[:default]
@sounds[channel] = @fmod.create_stream(full_path(filename), mode)
@channels[channel] = @fmod.play_sound(@sounds[channel], nil, true)
@channels[channel].volume = volume * 0.01
@channels[channel].pitch = pitch * 0.01
effects.reverse.each { |dsp| @channels[channel].add_dsp 0, dsp }
@channels[channel].resume
end
##
# Creates a DSP unit that can be applied to a channel to alter sound.
#
# Optionally applies it to channels if specified.
# @param type [Integer, Class] Either an integer value specified in the
# {DSP_TYPE} enumeration, or a class that is defined in the {FMOD::Effects}
# namespace.
# @param channels [Symbol] Any number of channel names defined in {CHANNELS}
# to apply the effect to.
# @return [FMOD::DSP] The created DSP effect.
def self.create_effect(type, *channels)
@effects << @fmod.create_dsp(type)
channels.each { |channel| add_effect(channel, @effects.last) }
@effects.last
end
##
# Adds an existing DSP effect to a channel.
# @param channel [Symbol] The name of the channel being played, a value
# defined in the {CHANNELS} array.
# @param effect [FMOD::DSP] AN existing DSP effect.
# @return [void]
def self.add_effect(channel, effect)
return unless channel_valid?(channel) && effect.is_a?(FMOD::DSP)
@channels[channel].add_dsp(0, effect)
end
##
# Removes an existing DSP effect from a channel.
# @param channel [Symbol] The name of the channel being played, a value
# defined in the {CHANNELS} array.
# @param effect [FMOD::DSP, Integer] Either the DSP effect instance to remove,
# or the index into the channel's DSP chain to remove.
# @return [void]
def self.remove_effect(channel, effect)
return unless channel_valid?(channel)
@channels[channel].remove_dsp(effect)
end
##
# Retrieves the loaded effect on the channel at the given index.
# @param channel [Symbol] The name of the channel being played, a value
# defined in the {CHANNELS} array.
# @param index [Integer] Index into the channel's DSP chain to retrieve the
# desired effect.
# @return [FMOD::DSP, nil] The desired DSP effect, or +nil+ if channel or
# index was invalid.
def self.effect(channel, index)
return unless channel_valid?(channel)
@channels[channel][index]
end
##
# Returns an array of created DSP effects.
# @param channel [Symbol] If specified, returns only effects currently applied
# to the channel.
# @return [Array<FMOD::DSP>] Array of all effects created by the user.
def self.effects(channel = nil)
return @effects if channel.nil?
return unless channel_valid?(channel)
@channels[channel].dsps
end
##
# Retrieves the current volume of a sound.
# @param channel [Symbol] The name of the channel being played, a value
# defined in the {CHANNELS} array.
# @return [Integer] The current volume, a value typically between 0 and 100.
def self.volume(channel)
channel_valid?(channel) ? (@channels[channel].volume * 100).to_i : 0
end
##
# Changes the volume of a channel.
# @param channel [Symbol] The name of the channel being played, a value
# defined in the {CHANNELS} array.
# @param volume [Integer] The volume to set, typically a value between 0 and
# 100, though amplification is supported.
# @return [Channel, nil] The channel object if channel was valid; otherwise
# +nil+.
def self.change_volume(channel, volume)
return unless channel_valid?(channel)
@channels[channel].volume = volume * 0.01
@channels[channel]
end
##
# Retrieves the current pitch of a sound.
# @param channel [Symbol] The name of the channel being played, a value
# defined in the {CHANNELS} array.
# @return [Integer] The current pitch, a value between 50 and 200.
def self.pitch(channel)
channel_valid?(channel) ? (@channels[channel].pitch * 100).to_i : 100
end
##
# Changes the pitch of a channel.
# @param channel [Symbol] The name of the channel being played, a value
# defined in the {CHANNELS} array.
# @param pitch [Integer] The pitch to set, a value between 50 (one octave
# down) and 200 (one octave up).
# @note Out of range values will be clamped.
# @return [Channel, nil] The channel object if channel was valid; otherwise
# +nil+.
def self.change_pitch(channel, pitch)
return unless channel_valid?(channel)
@channels[channel].pitch = pitch * 0.01
@channels[channel]
end
##
# Retrieves the current playback position of the sound.
# @param channel [Symbol] The name of the channel being played, a value
# defined in the {CHANNELS} array.
# @return [Integer] The current position, in milliseconds.
def self.position(channel)
channel_valid?(channel) ? @channels[channel].position : 0
end
##
# Moves the playback position to the specified location in the sound.
# @param channel [Symbol] The name of the channel being played, a value
# defined in the {CHANNELS} array.
# @param ms [Integer] The playback position, in milliseconds, to set.
# @return [Channel, nil] The channel object if channel was valid; otherwise
# +nil+.
def self.seek(channel, ms)
return unless channel_valid?(channel)
@channels[channel].seek(ms)
end
##
# Stops playback on the specified channel.
# @param channel [Symbol] The name of the channel being played, a value
# defined in the {CHANNELS} array.
# @return [Channel, nil] The channel object if channel was valid; otherwise
# +nil+.
def self.stop(channel)
return unless channel_valid?(channel)
@channels[channel].stop
end
##
# Pauses playback on the specified channel.
# @param channel [Symbol] The name of the channel being played, a value
# defined in the {CHANNELS} array.
# @return [Channel, nil] The channel object if channel was valid; otherwise
# +nil+.
def self.pause(channel)
return unless channel_valid?(channel)
@channels[channel].pause
end
##
# Resumes a previously paused channel, or does nothing if channel is invalid
# or not paused.
# @param channel [Symbol] The name of the channel being played, a value
# defined in the {CHANNELS} array.
# @return [Channel, nil] The channel object if channel was valid; otherwise
# +nil+.
def self.resume(channel)
return unless channel_valid?(channel)
@channels[channel].resume
end
##
# This is same as "time-stretching", changing the speed of playback without
# effecting the pitch of the sound.
# @param channel [Symbol] The name of the channel being played, a value
# defined in the {CHANNELS} array.
# @param speed [Float] A values between 0.5 (half speed) and 2.0 (double
# speed).
# @return [void]
def self.tempo(channel, speed)
return unless channel_valid?(channel)
speed = [0.5, speed, 2.0].sort[1]
pitch_shifter = @channels[channel].dsps.find { |dsp| dsp.type == 13 }
if pitch_shifter.nil?
pitch_shifter = create_effect(13, channel)
pitch_shifter.fft_size = 4096
end
@channels[channel].pitch = speed
pitch_shifter.pitch = 1.0 / speed
end
##
# Changes the playback speed of a sound. Altering the speed also changed the
# pitch.
#
# Negative values are accepted to play a sound backwards.
# @param channel [Symbol] The name of the channel being played, a value
# defined in the {CHANNELS} array.
# @param hz [Numeric] The frequency, in Hz, to play the channel at. Default
# speed is +44100+ Hz, so +-44100+ is normal speed backwards, +22050+ is
# half speed, +88200+ is double, etc.
def self.frequency(channel, hz)
return unless channel_valid?(channel)
@channels[channel].frequency = hz
end
##
# Transitions volume on the specified channel to the a target volume of the
# given number of milliseconds.
# @param channel [Symbol] The name of the channel being played, a value
# defined in the {CHANNELS} array.
# @param time [Integer] Length of time in milliseconds the transition will
# occur over.
# @param target_volume [Integer] The target volume to transition to, typically
# a value between 0 and 100, though amplification is supported.
# @note When volume is transitioned to +0+, the channel will be stopped when
# after reaching that volume.
def self.fade(channel, time, target_volume = 0)
return unless channel_valid?(channel)
volume = @channels[channel].volume - (target_volume * 0.01)
frames = time / Graphics.frame_rate
increment = volume / frames
@fade_points.delete_if { |fade| fade[0] == channel }
@fade_points.push [channel, frames, increment, target_volume]
end
##
# Checks if the specified channel is still valid and references an existing
# FMOD Channel. FMOD reuses channels automatically and can "steal" them for
# higher priority sounds when necessary, or more commonly after a sound has
# completed playing and the channel is stopped.
# @param channel [Symbol] The name of the channel being played, a value
# defined in the {CHANNELS} array.
# @return [Boolean] +true+ if channel is still valid; otherwise +false+.
def self.channel_valid?(channel)
return false if @channels[channel].nil?
@channels[channel].valid?
end
##
# Checks if the given channel is currently playing. If a filename is
# specified, additionally checks if that is the file being played.
# @param channel [Symbol] The name of the channel being played, a value
# defined in the {CHANNELS} array.
# @param filename [String, nil] A filename to check if is playing on the
# channel, or +nil+ to ignore.
# @return [Boolean] Returns +true+ if the channel is playing, and additionally
# if the filename is playing if specified; otherwise +false+.
def self.channel_playing?(channel, filename = nil)
return false unless channel_valid?(channel)
if @channels[channel].playing?
name = @channels[channel].sound.name
return File.basename(name, File.extname(name)) == File.basename(filename)
end
false
end
##
# Initializes the Audio module.
# @api private
# @return [void]
def self.initialize
return if defined? @fmod
@fmod = FMOD::System.create
build_paths
@found = {}
@sounds = {}
@channels = {}
@effects = []
@fade_points = []
end
##
# Updates the FMOD system object and fading.
# @note This is called automatically each frame with each Graphics update,
# there is no need to call this method manually.
# @api private
# @return [void]
def self.update
@fmod.update
@fade_points.delete_if do |fade|
if channel_valid?(fade[0])
@channels[fade[0]].volume -= fade[2]
fade[1] -= 1
if fade[1] <= 0
@channels[fade[0]].stop if fade[3] == 0
return true
end
return false
end
true
end
end
##
# Builds the search paths that will be used for searching audio files,
# including RTP paths defined in the projects .ini file.
# @return [void]
# @api private
def self.build_paths
ini = Win32API.new('kernel32','GetPrivateProfileString','pppplp','l')
ini.call(EXE, 'Library', '', lib = "\0" * 256, 256, "./#{EXE}.ini")
lib.delete!("\0")
rtp_path = Win32API.new(lib, 'RGSSGetRTPPath', 'l', 'l')
path_with_rtp = Win32API.new(lib, 'RGSSGetPathWithRTP', 'l', 'p')
@search_paths = [Dir.getwd]
(1..3).each do |i|
id = rtp_path.call(i)
rtp = path_with_rtp.call(id)
next if rtp == ''
@search_paths << rtp.gsub('\\', '/')
end
end
##
# Returns the full path from a relative path. File extension may be omitted,
# in which case any matching file that has an extension in the {EXTENSIONS}
# setting will be retrieved.
#
# Searches locally first, and RTP directories if not found.
# @param relative_path [String] A filename relative to the base directory.
# @api private
# @return [String] The full path to the file, including found extension.
# @raise [Errno::ENOENT] Raised when a matching file cannot be found.
# @note Once found, filenames are cached to reduce overhead on repeated calls.
def self.full_path(relative_path)
key = relative_path.to_sym
if @found.has_key?(key)
return @found[key]
end
catch :found do
@search_paths.each do |dir|
if File.extname(relative_path) != ''
filename = File.join(dir, relative_path)
next unless FileTest.exists?(filename)
@found[key] = filename
break
else
EXTENSIONS.each do |ext|
filename = File.join(dir, relative_path + ext)
next unless FileTest.exists?(filename)
@found[key] = filename
throw :found
end
end
end
end
# noinspection RubyResolve
raise Errno::ENOENT, relative_path unless @found.has_key?(key)
@found[key]
end
##
# @deprecated Use {Audio.play}. Backwards compatibility for existing Audio.
# Starts BGM playback. Sets the file name, volume, and pitch in turn.
#
# Also automatically searches files included in RGSS-RTP.
# @param filename [String] Relative filename to play. File extensions may be
# omitted.
# @param volume [Integer] Volume level. By default this within range of +0+
# to +100+, but FMOD supports amplification and higher values.
# @param pitch [Integer] Pitch level. +50+ to +200+.
# @return [void]
def self.bgm_play(filename, volume = 100, pitch = 100)
play(:BGM, filename, volume, pitch, true)
end
##
# @deprecated Use {Audio.play}. Backwards compatibility for existing Audio.
# Starts BGS playback. Sets the file name, volume, and pitch in turn.
#
# Also automatically searches files included in RGSS-RTP.
# @param filename [String] Relative filename to play. File extensions may be
# omitted.
# @param volume [Integer] Volume level. By default this within range of +0+
# to +100+, but FMOD supports amplification and higher values.
# @param pitch [Integer] Pitch level. +50+ to +200+.
# @return [void]
def self.bgs_play(filename, volume = 100, pitch = 100)
play(:BGS, filename, volume, pitch, true)
end
##
# @deprecated Use {Audio.play}. Backwards compatibility for existing Audio.
# Starts ME playback. Sets the file name, volume, and pitch in turn.
#
# Also automatically searches files included in RGSS-RTP.
# @param filename [String] Relative filename to play. File extensions may be
# omitted.
# @param volume [Integer] Volume level. By default this within range of +0+
# to +100+, but FMOD supports amplification and higher values.
# @param pitch [Integer] Pitch level. +50+ to +200+.
# @return [void]
def self.me_play(filename, volume = 100, pitch = 100)
play(:ME, filename, volume, pitch, false)
end
##
# @deprecated Use {Audio.play}. Backwards compatibility for existing Audio.
# Starts SE playback. Sets the file name, volume, and pitch in turn.
#
# Also automatically searches files included in RGSS-RTP.
# @param filename [String] Relative filename to play. File extensions may be
# omitted.
# @param volume [Integer] Volume level. By default this within range of +0+
# to +100+, but FMOD supports amplification and higher values.
# @param pitch [Integer] Pitch level. +50+ to +200+.
# @return [void]
def self.se_play(filename, volume = 100, pitch = 100)
play(:SE, filename, volume, pitch, false)
end
##
# @deprecated Use {Audio.stop}. Backwards compatibility for existing Audio.
# Stops BGM playback.
def self.bgm_stop
stop(:BGM)
end
##
# @deprecated Use {Audio.stop}. Backwards compatibility for existing Audio.
# Stops BGS playback.
def self.bgs_stop
stop(:BGS)
end
##
# @deprecated Use {Audio.stop}. Backwards compatibility for existing Audio.
# Stops ME playback.
def self.me_stop
stop(:ME)
end
##
# @deprecated Use {Audio.stop}. Backwards compatibility for existing Audio.
# Stops SE playback.
def self.se_stop
stop(:SE)
end
##
# @deprecated Use {Audio.fade}. Backwards compatibility for existing Audio.
# Transitions the BGM volume (up or down) to the target volume over the
# specified interval.
# @param time [Integer] The time in milliseconds for the transition to be
# applied. The volume will be adjusted on a per-frame basis.
# @return [void]
def self.bgm_fade(time, target_volume = 0)
fade(:BGM, time, target_volume)
end
##
# @deprecated Use {Audio.fade}. Backwards compatibility for existing Audio.
# Transitions the BGS volume (up or down) to the target volume over the
# specified interval.
# @param time [Integer] The time in milliseconds for the transition to be
# applied. The volume will be adjusted on a per-frame basis.
# @return [void]
def self.bgs_fade(time, target_volume = 0)
fade(:BGS, time, target_volume)
end
##
# @deprecated Use {Audio.fade}. Backwards compatibility for existing Audio.
# Transitions the ME volume (up or down) to the target volume over the
# specified interval.
# @param time [Integer] The time in milliseconds for the transition to be
# applied. The volume will be adjusted on a per-frame basis.
# @return [void]
def self.me_fade(time, target_volume = 0)
fade(:ME, time, target_volume)
end
##
# @deprecated Use {Audio.fade}. Backwards compatibility for existing Audio.
# Transitions the SE volume (up or down) to the target volume over the
# specified interval.
# @param time [Integer] The time in milliseconds for the transition to be
# applied. The volume will be adjusted on a per-frame basis.
# @return [void]
def self.se_fade(time, target_volume = 0)
fade(:SE, time, target_volume)
end
##
# Disposes the Audio module, releasing handles to the underlying objects.
# @return [void]
# @note It is strongly recommended not to alter or interfere with this call.
# @api private
def self.finalize
@channels.each_value { |channel| channel.stop if channel.valid? }
@effects.each { |dsp| dsp.release }
@sounds.each_value { |sound| sound.release }
@fmod.release
end
private_class_method :build_paths
private_class_method :full_path
private_class_method :finalize
initialize
end
module Graphics
class << self
##
# Original update method for Graphics.
# @return [void]
alias update_audio update
##
# Piggybacks the Graphics update of the Audio module. Updating is necessary
# for fading and some other less common functions.
# @return [void]
def update
Audio.update
update_audio
end
end
end
module Kernel
alias finalize_audio exit
def exit(*args)
begin
Audio.send(:finalize)
ensure
finalize_audio(*args)
end
end
end