Tonight I tried building an effect that emulates binaural audio. I made multiple versions starting with something pseudo-binaural and iterating on it to get it more realistic. This seems to be as far as I can take it, but maybe someone can recommend further improvements.
First attempt does no delay between the ears, just using panning and a couple LPFs to mimic how the sound might change when moved around the 3D space:
use_bpm 60
# Calculate volume attenuation based on distance
define :distance_attenuation do |x, y, z, intensity=1.0|
d = Math.sqrt(x**2 + y**2 + z**2)
max_dist = Math.sqrt(3)
norm_d = d / max_dist
amp = Math.exp(-norm_d * intensity * 5)
return [amp, 0.01].max
end
# Map z to LPF cutoff frequency
define :cutoff_z do |z|
if z < 0
ratio = (z + 1.0001)
cutoff = 40 + Math.log10(ratio * 10 + 1) * 90
else
ratio = (1.0001 - z)
cutoff = 100 + Math.log10(ratio * 10 + 1) * 30
end
return [cutoff, 130.9].min
end
# Map y to LPF cutoff frequency
define :cutoff_y do |y|
if y < 0
# y in [-1, 0] → cutoff 80 to 130
ratio = (y + 1.0001)
cutoff = 50 + Math.log10(ratio * 10 + 1) * 80 # 80 = 130 - 50, scaled logarithmically
else
# y in [0, 1] → cutoff 130 to 110
ratio = (1.0001 - y)
cutoff = 110 + Math.log10(ratio * 10 + 1) * 20
end
return [cutoff, 130.9].min
end
live_loop :random_3d_sample do
puts tick
x = rrand(-1.0, 1.0)
y = rrand(-1.0, 1.0)
z = rrand(-1.0, 1.0)
pan_pos = [[x, -1.0].max, 1.0].min
intensity = 1.0
amp = distance_attenuation(x, y, z, intensity)
lpf_cutoff_y = cutoff_y(y)
lpf_cutoff_z = cutoff_z(z)
with_fx :rlpf, cutoff: lpf_cutoff_y, res: 0 do
#with_fx :lpf, cutoff: lpf_cutoff_y do
with_fx :lpf, cutoff: lpf_cutoff_z do
with_fx :pan, pan: pan_pos do
sample :elec_beep, amp: amp
end
end
end
print "x:", x.round(2),
" y:", y.round(2),
" z:", z.round(2),
" amp:", amp.round(2),
" pan:", pan_pos.round(2),
" cut_y:", lpf_cutoff_y.round(1),
" cut_z:", lpf_cutoff_z.round(1),
" intensity:", intensity
sleep 0.25
end
Next, added delay and amp level difference to get it more spatial feeling. Also switched one LPF to a RLPF to give it a distinct timbre from the other filter:
# BINAURAL MODEL V2 - includes ITD + ILD (this is still not true delay accounting for human ear spacing).
use_bpm 60
define :distance_attenuation do |x, y, z, intensity=1.0|
d = Math.sqrt(x**2 + y**2 + z**2)
max_dist = Math.sqrt(3)
norm_d = d / max_dist
amp = Math.exp(-norm_d * intensity * 5)
return [amp, 0.01].max
end
define :cutoff_z do |z|
if z < 0
ratio = (z + 1.0001)
cutoff = 40 + Math.log10(ratio * 10 + 1) * 90
else
ratio = (1.0001 - z)
cutoff = 100 + Math.log10(ratio * 10 + 1) * 30
end
return [cutoff, 130.9].min
end
define :cutoff_y do |y|
if y < 0
ratio = (y + 1.0001)
cutoff = 50 + Math.log10(ratio * 10 + 1) * 80
else
ratio = (1.0001 - y)
cutoff = 110 + Math.log10(ratio * 10 + 1) * 20
end
return [cutoff, 130.9].min
end
live_loop :random_3d_sample do
puts tick
x = rrand(-1.0, 1.0)
y = rrand(-1.0, 1.0)
z = rrand(-1.0, 1.0)
intensity = 1.0
amp = distance_attenuation(x, y, z, intensity)
lpf_cutoff_y = cutoff_y(y)
lpf_cutoff_z = cutoff_z(z)
# Interaural Time Difference
interaural_delay = [x.abs * 0.01, 0.001].max
delay_left = x < 0 ? 0.001 : interaural_delay
delay_right = x > 0 ? 0.001 : interaural_delay
# Interaural Level Difference
left_amp = amp * (1.0 - x) / 2.0
right_amp = amp * (1.0 + x) / 2.0
with_fx :rlpf, cutoff: lpf_cutoff_y, res: 0 do
with_fx :lpf, cutoff: lpf_cutoff_z do
# Left Ear
with_fx :echo, phase: delay_left, mix: 0.4, decay: 0.05 do
with_fx :pan, pan: -1 do
sample :elec_beep, amp: left_amp
end
end
# Right Ear
with_fx :echo, phase: delay_right, mix: 0.4, decay: 0.05 do
with_fx :pan, pan: 1 do
sample :elec_beep, amp: right_amp
end
end
end
end
print "x:", x.round(2),
" y:", y.round(2),
" z:", z.round(2),
" amp_total:", amp.round(2),
" left_amp:", left_amp.round(2),
" right_amp:", right_amp.round(2),
" cut_y:", lpf_cutoff_y.round(1),
" cut_z:", lpf_cutoff_z.round(1),
" delay_L:", delay_left.round(4),
" delay_R:", delay_right.round(4)
sleep 0.25
end
Tweaked this by making variables for ear spacing and speed of sound to better calculate delay for each ear:
# BINAURAL MODEL V3 - includes ITD + ILD + delay based on ear spacing (Still doesn't fully model ear shape, pinna effect, and other reverb cues).
use_bpm 60
EAR_DISTANCE = 0.215 # Average human ear spacing in meters
SPEED_OF_SOUND = 343.0 # Speed of sound in m/s
MIN_DELAY = 0.001 # Minimal allowed delay for :echo phase (> 0)
define :distance_attenuation do |x, y, z, intensity=1.0|
d = Math.sqrt(x**2 + y**2 + z**2)
max_dist = Math.sqrt(3)
norm_d = d / max_dist
amp = Math.exp(-norm_d * intensity * 5)
return [amp, 0.01].max
end
define :cutoff_z do |z|
if z < 0
ratio = (z + 1.0001)
cutoff = 40 + Math.log10(ratio * 10 + 1) * 90
else
ratio = (1.0001 - z)
cutoff = 100 + Math.log10(ratio * 10 + 1) * 30
end
return [cutoff, 130.9].min
end
define :cutoff_y do |y|
if y < 0
ratio = (y + 1.0001)
cutoff = 50 + Math.log10(ratio * 10 + 1) * 80
else
ratio = (1.0001 - y)
cutoff = 110 + Math.log10(ratio * 10 + 1) * 20
end
return [cutoff, 130.9].min
end
live_loop :random_3d_sample do
puts tick
x = rrand(-1.0, 1.0)
y = rrand(-1.0, 1.0)
z = rrand(-1.0, 1.0)
intensity = 1.0
amp = distance_attenuation(x, y, z, intensity)
lpf_cutoff_y = cutoff_y(y)
lpf_cutoff_z = cutoff_z(z)
# Calculate Interaural Time Difference (seconds)
itd = (EAR_DISTANCE / SPEED_OF_SOUND) * x.abs
# Assign delay times, replace zero delays with MIN_DELAY
delay_left = x < 0 ? MIN_DELAY : itd
delay_right = x > 0 ? MIN_DELAY : itd
delay_left = [delay_left, MIN_DELAY].max
delay_right = [delay_right, MIN_DELAY].max
# Interaural Level Difference with a power curve for more natural attenuation
left_amp = amp * (x < 0 ? 1.0 : (1.0 - x)**1.5)
right_amp = amp * (x > 0 ? 1.0 : (1.0 + x)**1.5)
with_fx :rlpf, cutoff: lpf_cutoff_y, res: 0 do
with_fx :lpf, cutoff: lpf_cutoff_z do
# Left Ear
with_fx :echo, phase: delay_left, mix: 0.4, decay: 0.05 do
with_fx :pan, pan: -1 do
sample :ambi_choir, amp: left_amp
end
end
# Right Ear
with_fx :echo, phase: delay_right, mix: 0.4, decay: 0.05 do
with_fx :pan, pan: 1 do
sample :ambi_choir, amp: right_amp
end
end
end
end
print "x:", x.round(2),
" y:", y.round(2),
" z:", z.round(2),
" amp_total:", amp.round(2),
" left_amp:", left_amp.round(2),
" right_amp:", right_amp.round(2),
" cut_y:", lpf_cutoff_y.round(1),
" cut_z:", lpf_cutoff_z.round(1),
" delay_L:", delay_left.round(5),
" delay_R:", delay_right.round(5)
sleep 0.5
end
This last bit of code is almost the same, I just added in some math to rotate the sound source in a circle around the listener at ear level to better show off the effect:
# BINAURAL MODEL V3_RotatingSound - includes ITD + ILD + delay based on ear spacing (Still doesn't fully model ear shape, pinna effect, and other reverb cues).
# With math for making sound rotate around the listener.
use_bpm 60
EAR_DISTANCE = 0.215 # Average human ear spacing in meters
SPEED_OF_SOUND = 343.0 # Speed of sound in m/s
MIN_DELAY = 0.001 # Minimal allowed delay for :echo phase (> 0)
define :distance_attenuation do |x, y, z, intensity=1.0|
d = Math.sqrt(x**2 + y**2 + z**2)
max_dist = Math.sqrt(3)
norm_d = d / max_dist
amp = Math.exp(-norm_d * intensity * 5)
return [amp, 0.01].max
end
define :cutoff_z do |z|
if z < 0
ratio = (z + 1.0001)
cutoff = 40 + Math.log10(ratio * 10 + 1) * 90
else
ratio = (1.0001 - z)
cutoff = 100 + Math.log10(ratio * 10 + 1) * 30
end
return [cutoff, 130.9].min
end
define :cutoff_y do |y|
if y < 0
ratio = (y + 1.0001)
cutoff = 50 + Math.log10(ratio * 10 + 1) * 80
else
ratio = (1.0001 - y)
cutoff = 110 + Math.log10(ratio * 10 + 1) * 20
end
return [cutoff, 130.9].min
end
live_loop :random_3d_sample do
# Use a tick to get an angle in radians
angle = tick * (Math::PI / 16) # Adjust step size for speed of rotation
x = Math.cos(angle) # Moves from 1 to -1 in cosine wave
y = Math.sin(angle) # Moves from 0 to 1 to 0 to -1 in sine wave
z = 0 # Ear level
intensity = 1.0
amp = distance_attenuation(x, y, z, intensity)
lpf_cutoff_y = cutoff_y(y)
lpf_cutoff_z = cutoff_z(z)
# Calculate Interaural Time Difference (seconds)
itd = (EAR_DISTANCE / SPEED_OF_SOUND) * x.abs
# Assign delay times, replace zero delays with MIN_DELAY
delay_left = x < 0 ? MIN_DELAY : itd
delay_right = x > 0 ? MIN_DELAY : itd
delay_left = [delay_left, MIN_DELAY].max
delay_right = [delay_right, MIN_DELAY].max
# Interaural Level Difference with a power curve for more natural attenuation
left_amp = amp * (x < 0 ? 1.0 : (1.0 - x)**1.5)
right_amp = amp * (x > 0 ? 1.0 : (1.0 + x)**1.5)
with_fx :rlpf, cutoff: lpf_cutoff_y, res: 0 do
with_fx :lpf, cutoff: lpf_cutoff_z do
# Left Ear
with_fx :echo, phase: delay_left, mix: 0.4, decay: 0.05 do
with_fx :pan, pan: -1 do
sample :ambi_choir, amp: left_amp
end
end
# Right Ear
with_fx :echo, phase: delay_right, mix: 0.4, decay: 0.05 do
with_fx :pan, pan: 1 do
sample :ambi_choir, amp: right_amp
end
end
end
end
print "x:", x.round(2),
" y:", y.round(2),
" z:", z.round(2),
" amp_total:", amp.round(2),
" left_amp:", left_amp.round(2),
" right_amp:", right_amp.round(2),
" cut_y:", lpf_cutoff_y.round(1),
" cut_z:", lpf_cutoff_z.round(1),
" delay_L:", delay_left.round(5),
" delay_R:", delay_right.round(5)
sleep 0.5
end
It would be really cool if there was a built-in binaural effect within Sonic Pi… would make for some awesome soundscaping.