Manipulating perceived position of stereo sound

Before I start, kudos and thanks to Narbotic and others for all the pioneering work on sound generation. I just noticed Narbotic's stereo beat generator: http://www.arduino.cc/cgi-bin/yabb2/YaBB.pl?num=1219732829... and I'll say right up front that I have not mastered advanced methods, so all I will be doing is bit-banging some low-frequency audio. Think of this as a proof of concept.

Oh, and apologies for my long-winded style. I undercomment my code and overcomment my prose.

I was playing around with Sparkfun's pricey HMC6343 tilt-compensated compass and after the obvious cylon LED array, needed another output method, so decided to play with a stereo beep that would seem to track a fixed azimuth as you rotated the board. Here's the hardware on a breadboard, and mark II on protoboard:

I shamelessly stole code left and right from the trailblazers and kludged together something that uses analogWrite() with a modified PWM frequency on pins 9 and 10 to generate low frequency bit-banged sound with independent volume control on right and left channels. It wasn't pretty, but it worked, after a fashion. Alas, as you panned the audio from side to side, the effect was not as crisp as would have liked in the middle range, so I decided to do some research.

After reading this: http://www.hitl.washington.edu/scivw/EVE/I.B.1.3DSoundSynthesis.html I tried to add some delay between the two audio 'tracks'. It works out that the delay is never greater than about 650us, so if you pick your beep's frequency low enough, the delay can be contained inside a single cycle of your audio frequency, which is rather handy. Now I just needed to figure out how to vary the delay with the heading, and some high school geometry helped:

Turns out that the delay for stereo sound is proportional to sine of that heading (measured from dead ahead). The delay for an echo from a backstop (which I have not implemented yet) is proportional to the cosine of the same angle (with a different base value, unless the backstop is somewhere inside your skull).

So was all this trig and delayMicrosecond() worth the bother? And how!

The effect is so sharp, you can tell the difference between center, ten degrees left of center and ten degrees right of center. I was so surprised, I thought it might be due to wishful thinking, so I turned off the stereo volume adjustments... even with just the delay and no change in the volume between left and right, you can hear the sound move from side to side. What's even weirder is that if you listen to just one side, the sound stays the same.

With both volume and delay adjustments, the stereo sound effect is very noticeable, with a clear distinction around the center mark.

I'll try to post my code here in a reply, or I'll provide a link to it on my blog, but I'd appreciate it if someone tried my code out, perhaps with just a pot and analogRead() to pan left/right? Confirmation from someone else would be great.

Code won't fit here. I've posted it on my blog here: ban-sidhe.com - This website is for sale! - ban sidhe Resources and Information.

Nevermind, took out some serial output and it fits now. :slight_smile:

// Generating a low frequency beep that seems to always come from the South
// by Mathieu Glachant @ http://ban-sidhe.com
// with shameless pilfering of much good code from the trailblazers at arduino.cc
// Public release v0.1 Labor day 2008

#include <Wire.h>

int AudioOutputOn =true; //Toggles audio ouput of measurements

int HMC6343Address = 0x32;
int slaveAddress; // This is calculated in the setup() function

byte inByte;

byte headingData[6];
int i;

long time = 0;

int headingValue =0;
int tiltValue =0;
int rollValue =0;

int audioPinR =9; // This is the pin for the right channel audio bit-banging
int audioPinL =10; // This is the pin for the right channel audio bit-banging
// Note that these should not be in output mode unless needed,
// so they are not included in setup()

void setup()
{

TCCR1B = 0x09; // Make PWM frequency more audio friendly on pins 9 and 10

// Shift the device's documented slave address (0x32) 1 bit right
// This compensates for how the TWI library only wants the
// 7 most significant bits (with the high bit padded with 0)

slaveAddress = HMC6343Address >> 1; // This results in the 7 bit address to pass to TWI

while( millis() < 500) { delay(10); } // The HMC6343 needs a half second to start from power up

Wire.begin();

}

void getSensorData()
{

Wire.beginTransmission(slaveAddress);
Wire.send(0x50); // Send a "Get Data" (0x50) command to the HMC6343
Wire.endTransmission();

delay(2); // The HMC6343 needs at least a 1ms (microsecond) delay
// after this command. Using 2ms just makes it safe

// Read the 6 heading bytes, MSB first
// The resulting 3 16bit words are the compass heading, the tilt and roll in 10th's of a degree
// For example: a heading of 1345 would be 134.5 degrees

Wire.requestFrom(slaveAddress, 6); // Request the 6 bytes of data (MSB comes first)
i = 0;
while(Wire.available() && i < 6)
{
headingData = Wire.receive();

  • i++;*

  • }*
    }
    void convertDataToValues()
    {
    _ headingValue = headingData[0]*256 + headingData[1]; // Put the MSB and LSB together_
    _ tiltValue = headingData[2]*256 + headingData[3] + 900; // Put the MSB and LSB together_
    _ rollValue = headingData[4]*256 + headingData[5] + 1800; // Put the MSB and LSB together_
    }
    *void stereoSound(int freq, int t, int phase, boolean phaseSign, int volumeR, int volumeL) *

  • // freq in hz, t in ms, phase in us, phaseSign true if positive, volume from 0 to 255*
    {

  • int hperiod; //calculate 1/2 period in us*

  • int audioPin1 =audioPinL;*

  • int audioPin2 =audioPinR;*

  • int volume1 =volumeL;*

  • int volume2 =volumeR;*

  • long cycles, i;*

  • //pinMode(audioPinR, OUTPUT); // turn on output pin, not needed for analogWrite*

  • //pinMode(audioPinL, OUTPUT); // turn on output pin, not needed for analogWrite*

  • hperiod = (500000 / freq) - 7; // subtract 7 us to make up for digitalWrite overhead*
    _ cycles = ((long)freq * (long)t) / 1000; // calculate cycles_

  • phase = max(3,phase);*

  • if (phaseSign)*

  • {*

  • audioPin1 =audioPinR;*

  • audioPin2 =audioPinL;*

  • volume1 =volumeR;*

  • volume2 =volumeL;*

  • }*

  • analogWrite(audioPin1, volume1);*

  • for (i=0; i<= cycles; i++){ // play note for t ms*

  • delayMicroseconds(phase);*

  • analogWrite(audioPin2, volume2);*

  • delayMicroseconds(hperiod-phase);*

  • analogWrite(audioPin1, 0);*

  • delayMicroseconds(phase);*

  • analogWrite(audioPin2, 0);*

  • delayMicroseconds(hperiod -phase - 1); // - 1 to make up for fractional us*

  • analogWrite(audioPin1, volume1);*

  • }*

  • delayMicroseconds(hperiod);*

  • analogWrite(audioPin1, 0);*

  • //pinMode(audioPinR, INPUT); // shut off pin to avoid noise from other operations, not needed for analogWrite*

  • //pinMode(audioPinL, INPUT);*

}
void delayLoop(int duration)
{

  • while( (millis() - time) < duration) { delay(1); }*

  • time = millis();*
    }
    void loop()
    {

  • getSensorData();*

  • convertDataToValues();*

  • if (AudioOutputOn)*

  • {*

  • stereoSound(*

  • 150, // frequency of beep*

  • 40, // duration of beep*
    _ abs(int(1200*sin(float(headingValue-1800)*0.001745))), // phase shift in us_

  • (headingValue < 1800), // phase shift sign (true if right before left)*

  • constrain(map(headingValue,2700,1800,0,255),0,255),*

  • constrain(map(headingValue,1800,900,255,0),0,255)*

  • );*

  • }*

  • delayLoop(200); // The HMC6343 needs 200ms between measurement*

  • // this function will make sure loop() lasts at least that long*
    }

Really Nice post, man

Thanks! :slight_smile:

... ou plutot, merci cher compatriote!

Mathieu

George asked for a schematic of the output circuit I used to connect to the headphones, so here it is:

Nothing special, it just biases the signal to half your supply voltage (so that it oscillates between +1/2Vs and -1/2Vs instead of between Vs and 0) and filters a little of the nasty harmonics from the square waves... nothing too aggressive. Feel free to experiment a little with the capacitor values, particularly if you can simulate the circuit with something like LTspice.

Volume level is baked in by the ratio of the two series resistors on the right side, but keep in mind that you don't want to feed more than one volt to your headphones. This schematic assumes a 3.3V Arduino, so people with 5V versions (the majority) or people who aren't sure should probably replace the 3.3K resistors with something bigger to keep the signal going into your headphones at a reasonable level... at least 4.7K or perhaps even more depending on what a comfortable volume level is.

I've left out volume control because that would require an audio-taper double pot and this is a proof of concept design at this stage. In the final version I'd probably add one, which would take the place of the two 1K resistors on the right hand side while the 3.3K resistors would set the max volume.

George asked for some code that didn't require a sensor, so here is a simple sweeper that cycles the incidence angle thru 0-360 degrees. There are two versions of the stereoSound() function. One uses analogWrite() to add volume effects, the other doesn't. You can try both, just comment out the one you don't want in the main loop()...

// Generating a low frequency beep that seems to sweep back and forth
// by Mathieu Glachant @ http://ban-sidhe.com
// with shameless pilfering of much good code from the trailblazers at arduino.cc
// Public release v0.1 September 10th 2008


float sweep=0;

int audioPinR =9;  // This is the pin for the right channel audio bit-banging
int audioPinL =10; // This is the pin for the right channel audio bit-banging
                   // Note that these should not be in output mode unless needed,
                   // so they are not included in setup()

void setup()
{
  
  TCCR1B = 0x09; // Make PWM frequency more audio friendly on pins 9 and 10

}

void stereoSound(int freq, int t, int phase, boolean phaseSign, int volumeR, int volumeL)  
                 // freq in hz, t in ms, phase in us, phaseSign true if positive, volume from 0 to 255
{
  int hperiod;                               //calculate 1/2 period in us
  int audioPin1 =audioPinL;
  int audioPin2 =audioPinR;
  int volume1 =volumeL;
  int volume2 =volumeR;
  long cycles, i;
  
  //pinMode(audioPinR, OUTPUT);               // turn on output pin, not needed for analogWrite
  //pinMode(audioPinL, OUTPUT);               // turn on output pin, not needed for analogWrite

  hperiod = (500000 / freq) - 7;             // subtract 7 us to make up for digitalWrite overhead

  cycles = ((long)freq * (long)t) / 1000;    // calculate cycles
  
  phase = max(3,phase);
  
  if (phaseSign)
  {
    audioPin1 =audioPinR;
    audioPin2 =audioPinL;
    volume1 =volumeR;
    volume2 =volumeL;
  }
  
    analogWrite(audioPin1, volume1); 
  for (i=0; i< cycles; i++){                // play note for t ms 
    delayMicroseconds(phase);
    analogWrite(audioPin2, volume2); 
    delayMicroseconds(hperiod-phase);
    analogWrite(audioPin1, 0);
    delayMicroseconds(phase);
    analogWrite(audioPin2, 0); 
    delayMicroseconds(hperiod -phase - 1);  // - 1 to make up for fractional us
    analogWrite(audioPin1, volume1); 
  }
    delayMicroseconds(phase);
    analogWrite(audioPin2, volume2); 
    delayMicroseconds(hperiod-phase);
    analogWrite(audioPin1, 0);
    delayMicroseconds(phase);
    analogWrite(audioPin2, 0); 
  
  //pinMode(audioPinR, INPUT);                // shut off pin to avoid noise from other operations, not needed for analogWrite
  //pinMode(audioPinL, INPUT);
  
}

void stereoSound2(int freq, int t, int phase, boolean phaseSign)
                 // freq in hz, t in ms, phase in us, phaseSign true if positive
{
  int hperiod;                               //calculate 1/2 period in us
  int audioPin1 =audioPinL;
  int audioPin2 =audioPinR;
  long cycles, i;
  
  pinMode(audioPinR, OUTPUT);               // turn on output pin, not needed for analogWrite
  pinMode(audioPinL, OUTPUT);               // turn on output pin, not needed for analogWrite

  hperiod = (500000 / freq) - 7;             // subtract 7 us to make up for digitalWrite overhead

  cycles = ((long)freq * (long)t) / 1000;    // calculate cycles
  
  phase = max(3,phase);
  
  if (phaseSign)
  {
    audioPin1 =audioPinR;
    audioPin2 =audioPinL;
  }
  
    digitalWrite(audioPin1, HIGH); 
  for (i=0; i< cycles; i++){                // play note for t ms 
    delayMicroseconds(phase);
    digitalWrite(audioPin2, HIGH); 
    delayMicroseconds(hperiod-phase);
    digitalWrite(audioPin1, LOW);
    delayMicroseconds(phase);
    digitalWrite(audioPin2, LOW); 
    delayMicroseconds(hperiod -phase - 1);  // - 1 to make up for fractional us
    digitalWrite(audioPin1, HIGH); 
  }
    delayMicroseconds(phase);
    digitalWrite(audioPin2, HIGH); 
    delayMicroseconds(hperiod-phase);
    digitalWrite(audioPin1, LOW);
    delayMicroseconds(phase);
    digitalWrite(audioPin2, LOW); 
  
  pinMode(audioPinR, INPUT);                // shut off pin to avoid noise from other operations, not needed for analogWrite
  pinMode(audioPinL, INPUT);
  
}

void loop()
{
  
 if (sweep > 359) { sweep = 0; } 
  
  //stereoSound(150,20,abs(int(630*sin(float(sweep-180)*0.01745))),(sweep < 180),constrain(map(sweep,270,180,0,255),0,255),constrain(map(sweep,180,90,255,0),0,255));

  stereoSound2(100,50,abs(int(630*sin(float(sweep-180)*0.01745))),(sweep < 180));

  sweep = sweep + 1;

  delay(1);

}

Matthieu,
You Rock! I tried your code and audio schematic and it works great. Of course, not perfect (as you warned, the tone is just bitbanged and needs some cleanup), but very cool. As you said, when you listen, it is easy to wonder if you are deluding yourself about the perceived position of the sound source. I put an analogRead and a Potentiometer in the code and when you can control it directly, the tone's spatial orientation gives a very fun feedback to your inputs. Thanks so much for your contribution!
George
PS I don't know if maybe this question is addressed somewhere else, but I am wondering if you or anyone else has figured out how to manipulate the front/back spatial perception. I can sense it moving side to side, but not front to back. When you had it set up for compass input, you could hear the East and West deviation, but not distinguish between North and South, right?

Hi George, and thanks :slight_smile:

I've been reading up on spatial placement of sound and it is apparently a rather tough nut to crack. For low frequency sounds like here, ITD (Interaural Time Difference) is the main sensory cue which explains why this hack works so well, while for higher frequency sounds, ILD (Interaural Level Difference) predominates which means you need to play with volume levels.

The good news is thus that for our purposes here, the analogWrite() version is not needed and bitbanging with digitalWrite is plenty.

The bad news is that to achieve placement of the sound outside of the head, and in particular to resolve the front/back ambiguity, the effect of your head and the fleshy bits of your external ear come into play. This has complex impacts on both volume levels and phase shifts, as well as creating echoes which interact with the main sound at each ear... Apparently this can be simulated by applying time domain filters which are outside the capacities of an Arduino.

Having said that, I'm still hopeful for two reasons:

  1. For front/back discrimination, the compass actually helps by adding head motion tracking. It is a natural response to change the position of your head as you try to hear where the sound is coming from... and the perceived sound moves one way when it is from the front, and another when it is from the rear. This diagram is worth a thousand of my words (I found it here) :

Note that you don't need tilt-compensation for this to work well, so a cheaper alternative to the HMC6343 would be fine. In fact, it may very well be that since we're pretty good at sensing head tilt we can do our own tilt-compensation making a compass-hat much cheaper.
2. The filters must be simpler for a simple sine tone at a fixed frequency coming from a large distance away... which is what I'm reading up on now. If they are much simpler and can be approximated with the horsepower of an Arduino, we might go part of the way to positioning the sound front/back and up/down.

Right now, having left my compass back in the states, I'm playing with better sound synthesis techniques to make a cleaner tone, perhaps even play back samples with controllable azimuth.

Duh... :o

About tilt-compensation... I just realized there is no need for tilt compensation around magnetic north and magnetic south, while the need is greatest for east and west. There would be some sensitivity to roll, of course, but I think pointing your nose north with a compass hat based on a non-compensated sensor should be easy and accurate.

As for a navigation-hat with GPS that points towards a given destination... I suppose it would be more sensitive to tilt if the destination lay east or west, but again, we do a decent job holding our heads level so it should just work.

Lastly, if you have a dual-axis accelerometer you could fade the sound in and out or manipulate its pitch the more level your head position was, to reinforce the effect.

Updated version using the TimerOne library for higher frequency PWM. Volume control now sounds clean(er), and it's just a matter of figuring out how to modulate left/right volume levels with the incidence angles. I will probably replace the calculations with some arrays of pre-calculated values at some point, to speed things up, but for now it's simpler to calculate inline.

// Generating a low frequency beep that seems to sweep back and forth
// using TimerOne library
// by Mathieu Glachant @ http://ban-sidhe.com
// with shameless pilfering of much good code from the trailblazers at arduino.cc
// Public release v0.1 September 13th 2008

#include "TimerOne.h"

float sweep=0;
int basePeriod=10; // PWM period in uS

int audioPinR =9;  // This is the pin for the right channel audio bit-banging
int audioPinL =10; // This is the pin for the right channel audio bit-banging

void setup()
{
  pinMode(audioPinR, OUTPUT);    // turn on output pin
  pinMode(audioPinL, OUTPUT);    // turn on output pin
  Timer1.initialize(basePeriod); // Initializes Timer1
  Timer1.pwm(audioPinR, 512);    // Activate PWM on right channel
  Timer1.pwm(audioPinL, 512);    // Activate PWM on left channel
}

void stereoSound(int freq, int t, int phase, boolean phaseSign, int volumeR, int volumeL)  
                 // freq in hz, t in ms, phase in us, phaseSign true if positive, volume from 0 to 1023
{
  int hperiod;                               //calculate 1/2 period in us
  int audioPin1 =audioPinL;
  int audioPin2 =audioPinR;
  int volume1 =volumeL;
  int volume2 =volumeR;
  long cycles, i;
  
  hperiod = (500000 / freq) - 7;             // subtract 7 us to make up for digitalWrite overhead

  cycles = ((long)freq * (long)t) / 1000;    // calculate cycles
  
  phase = max(3,phase);
  
  if (phaseSign)
  {
    audioPin1 =audioPinR;
    audioPin2 =audioPinL;
    volume1 =volumeR;
    volume2 =volumeL;
  }
  
    Timer1.setPwmDuty(audioPin1, volume1); 
  for (i=0; i< cycles; i++){                // play note for t ms 
    delayMicroseconds(phase);
    Timer1.setPwmDuty(audioPin2, volume2); 
    delayMicroseconds(hperiod-phase);
    Timer1.setPwmDuty(audioPin1, 0);
    delayMicroseconds(phase);
    Timer1.setPwmDuty(audioPin2, 0); 
    delayMicroseconds(hperiod -phase - 1);  // - 1 to make up for fractional us
    Timer1.setPwmDuty(audioPin1, volume1); 
  }
    delayMicroseconds(phase);
    Timer1.setPwmDuty(audioPin2, volume2); 
    delayMicroseconds(hperiod-phase);
    Timer1.setPwmDuty(audioPin1, 0);
    delayMicroseconds(phase);
    Timer1.setPwmDuty(audioPin2, 0); 
  
}

void loop()
{
  
 if (sweep > 359) { sweep = 0; } 
  
  stereoSound(100,25,abs(int(630*sin(float(sweep-180)*0.01745))),(sweep < 180),
  1023,
  //constrain(map(sweep,270,180,0,1023),0,1023),
  1023
  //constrain(map(sweep,180,90,1023,0),0,1023)
  );

  sweep = sweep + 1;

  //delay(1);

}

Interesting project, thanks for sharing.

One thing seemed odd, your calculation for the period has the comment: // subtract 7 us to make up for digitalWrite overhead

I don't think any code in that sketch calls digitalWrite, and if it did it would only take half that time to turn on and off a pulse. If you do update the code to use lookups, you may want to check out why you need that 7us factor.

Hi Mem, and thanks.

You're absolutely correct. That comment is a hold over from the original form which did use digitalWrite() and the 7us figure is straight from the examples in the playground I stole the original code from.

I need to write a little sketch to toggle the pins as fast I can to measure the delay using the laptop's sound card as an oscilloscope (single channel, alas) to update that value to something with more relation to the truth. Consider it added to the to-do list! :slight_smile:

I need to write a little sketch to toggle the pins as fast I can to measure the delay using the laptop's sound card as an oscilloscope

I presume the pins are actually toggled by the timer1 hardware, so I am not sure what you want to measure. Do you have any reason to believe that the frequency produced without adding a fudge factor in your current code is wrong? Don't you just need to verify that the sound output frequency is close to the calculated value.

Actually, TimerOne sets the PWM frequency and duty cycle. I then 'manually' (as it were) toggle the duty cycle from 0 to 1023 at a much lower frequency (100Hz) to generate the audio frequency tone, the volume of which is thus controlled by the PWM duty cycle. As long as the delay between left and right side is less than the half-period of this low frequency, this allows me to control both the Interaural Time Difference and the Interaural Level Difference easily.

What I don't know is how much time it takes to change the duty cycle on a given pin using the TimerOne library, and therefore how much that time distorts the output, but to measure it, I would set PWM frequency, and then set duty cycle to 1023, then to zero, then back as fast as I can, and measure how many PWM cycles got between changes, for example, or the delay between changes.

Having said all that, it seems to work fine the way it is, so I don't think it's critical. In fact, I'll try it without the fudge factor.

You could measure the delay, but wouldn't just measuring the output frequency be just as good? If so, I will be happy to connect up a frequency counter and do the measurement if you post a test sketch.

You're right of course, altho the period for 100Hz is 10,000 uS, so each uS of delay for the duty cycle change is 0.01% error (which tends to argue there is no need for a fudge factor at this frequency)... I'm not sure which is easier to measure, frequency or delay. In any case, thank you for your kind suggestion!

Here is some code that just generates a stable 100Hz tone from a 100kHz PWM signal at full duty cycle. You should be able to change the freq and fudgeFactor if you want to try other values than the default 100hZ and 0uS.

// Generating a low frequency tone
// using TimerOne library for volume control
// Test sketch to measure output frequency
// by Mathieu Glachant @ http://ban-sidhe.com
// with shameless pilfering of much good code from the trailblazers at arduino.cc
// Public release v0.1 September 13th 2008

#include "TimerOne.h"

int basePeriod=10; // PWM period in uS

int freq = 100;    // in Hz <== this is the frequency I'm hoping to generate
int hperiod;       // calculate 1/2 period in us
int fudgeFactor=0; // <== hopefully changing this will allow to adjust until we get the right frequency

int audioPin =9;   // This is the pin for the tone generation

void setup()
{
  pinMode(audioPin, OUTPUT);     // turn on output pin

  Timer1.initialize(basePeriod); // Initializes Timer1
  Timer1.pwm(audioPin, 0);       // Activate PWM on output channel
  
  hperiod = (500000 / freq) - fudgeFactor;

}

void loop()
{
  
    Timer1.setPwmDuty(audioPin, 1023); 
    
    delayMicroseconds(hperiod);
    
    Timer1.setPwmDuty(audioPin, 0);
    
    delayMicroseconds(hperiod - 1);  // - 1 to make up for fractional us

}

PS: The sun's coming out from behind the rain clouds here, so I'll be AFK for a bit. :slight_smile:

PS: The sun's coming out from behind the rain clouds here, so I'll be AFK for a bit. :slight_smile:

Likewise, I will report on results later....

The test sketch gave the following times:

freq =100, the measured period was 10.04 ms
freq=1000, period was 1.03ms
freq=4000, period was 280us

therefore the error was in the order of 30us

using a fudgfactor of 10 resulted in timings that were exactly 250us for freq=4000 and better than 1.01ms for freq=1000

Awesome! :smiley:

Thank you, Mem, you da bomb...

I'll update the code and give it a whirl.