Binaural Audio FX (Pseudo-binaural)

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.

2 Likes

Perhaps not quite the same, but it has been on my todo list to introduce a new FX for Sonic Pi called :haas, which adds a haas effect.

1 Like

That would be awesome. I think that may be an important factor in making this more realistic. There’s a lot of other things I was researching like how the ear reflect and absorbs sound, shadowing from the head, etc… It seems like a fairly complicated thing to simulate so I’m glad I could at least get something feeling baseline realistic.

1 Like

I’m also now just wondering if my sound sample should be monophonic or whether this would have any bearing on the end result. Might have to play around with that too.

I haven’t tried the haas effect with a true stereo input - just a duplicated mono signal. Might be worth a try? I’m guessing the more similar the signals are, the more pronounced the effect :man_shrugging: