Drone FX

Just got Drone FX approved. And thought I’d share some of the nitty-gritty discoveries I made along the way with ya’ll.

You can learn more about the app here:
http://www.drumbot.com/projects/dronefx/

Drone FX is an ambient music / generative / soundscape thing, which was spun off of my first app Key Chords.

After creating Key Chords, I realized that when I slowed everything down, some interesting melodies were created. And so I thought, why not replace the guitar notes with fatter, looping sounds and spread things out (time-wise)?

The concept was to have a whole bunch of “instruments” available, so as to allow for a wide variety of sounds. If you’re familiar with Brian Eno’s “Bloom” or any of the other ambient music apps out there, the music, although nice, is limited to just a few sounds. And things get old real quick.

I wanted to have tons of options for the user.

This meant that in order to avoid having an over-bloated 3 GB download, I would need to pitch-shift to cover a few octaves – thereby only requiring a few wav samples per instrument. So with the concept in hand, I marched onward.

Knowing full-well the limitations of Corona’s audio library, (due to my experience with Key Chords) I decided to trudge forward (reluctantly) and learn Objective-C.

With a fresh Objective-C ebook in hand, I methodically grasped the concepts of the Martian-like language. But, being the script baby that I am, I soon found myself scurrying around for an easier solution.

And then I happened upon this post:

The secret/undocumented audio APIs in Corona SDK
http://blog.anscamobile.com/2011/07/the-secretundocumented-audio-apis-in-corona-sdk/

And a glimmer of hope seeped through the “messaging.” I just MIGHT be able to use Corona!

And so, Drone FX was born.
Here are few things I learned through the process

Panning
Not as straight forward as you might think… this whole 3D sound is weird. Bottom line is that you need to use trigonometry to achieve left-to-right as in:

local \_sourceAudio = audio.loadSound("myTrack.mp3")  
local \_options = {channel=3, loops=-1 }  
local \_pan = 1.5 -- range is -1.5 to +1.5)  
  
local \_chnl, \_src = audio.play(\_sourceAudio, \_options )  
al.Source(\_src, al.POSITION, \_pan, 0.0, math.cos(\_pan) )  
  

Timing
Keeping accurate timing is, well, not so accurate. I’m well aware of this concept from my Actionscript days.

There are a number of ways to approach this.

  1. Using system.getTimer() / enterFrame
  2. Using system.getTimer() / timer
  3. Using a wav / callback

#1 and #2 are pretty much the same, and are established as:

  
local syncTimer = {} -- forward reference the variable to keep the proper scope. (Only needed if using the "timer" method.)  
  
local timerSpeed = 10 -- if using the "timer" method, calls occur every 10 milliseconds  
local currentTime = 0  
local prevTime = 0  
local eventTrigger = 1000 -- trigger an event every second  
  
local function syncRuntime(event)  
 currentTime = system.getTimer()  
 if (currentTime-prevTime) \>= eventTrigger then  
 -- do something  
 end  
 prevTime = currentTime  
end  
  
function start()  
 -- If using the timer method:  
 -- syncTimer = timer.performWithDelay(timerSpeed, syncRuntime, 0)  
  
 -- If opting for the enterFrame emthod   
 Runtime:addEventListener("enterFrame", syncRuntime)  
end  
  
function stop()  
 -- If using the timer method:  
 -- timer.cancel( syncTimer )  
  
 -- If opting for the enterFrame emthod  
 Runtime:removeEventListener("enterFrame", syncRuntime)  
end  
  
-- Start ASAP:  
start()  
  

The third option is to use the callback feature of a playing sound.

The idea is that you occupy one of your 32 channels with a “silent” wav. This silent wav is set to have a duration in accordance with how often you want your sync function to get pinged.

So the way this works is, if your “silence.wav” is 100 milliseconds in duration, then when it is finished playing we leverage the audio callback feature to ping the sync function, which re-loads the “silent” wav and established a loop that runs in increments directly related to the duration of the “silent.wav” file.

local function syncRuntime()  
 -- do something  
end  
  
local syncChannel = 1  
local syncAudioFile = "\_sync\_control\_silence\_10ms.caf" -- The file's duration should be however many milliseconds you need to keep time. e.g. 10 millisonds, 100 millisonds, etc.  
local syncLoader = audio.loadStream(syncAudioFile)  
  
local function sync\_by\_audio()  
 syncRuntime()  
 -- We're setting the "onComplete" option, hence, this function gets called once the sound is complete, thereby setting up a loop that "clicks" at intervals determined by the duration of the control file.  
 audio.play(syncLoader, {channel = syncChannel, onComplete=function() sync\_by\_audio() end})  
end  
sync\_by\_audio()  

I originally developed the wav file duration setup hoping that it would be a work-around to keep the app running while in the background. However, when an app is in the background, Corona essentially shuts down, so the “onComplete” event never fires.

I ultimately decided to go with the enterFrame method, simply because it was the easiest, and tied to the screen refresh, so it seems the most robust. Using the timer works great too, but I’m a little weary of timers in general, plus it seems like timers are not as “close” to the underlying runtime. But it’s all voodoo to me.
Destroying Audio
One other issue was the “build up” (in memory) of loaded sounds. Each time loadSound() is called, the wav data gets stored in memory until it is explicitly released via audio.dispose(). This sounds like an easy thing to do, but it’s not when you are preloading files and playing them from separate functions or “classes”.

The reason its tricky is because you can not “dispose” of a loaded sound unless it’s stopped. Which means you need to know the channel number and source handle prior to calling audio.dispose(). Because you need the channel number to call the audio.stop(channel), and the source handle to call the audio.dispose(source).

So the trick is to maintain and array that cross-references the channel to the source.

  
-- Array to hold the cross-reference between channels and sources  
local channel2source = {}  
  
-- Pre-populate the array with all the available channels. (Currently, there are 32 only 32 channels available in Corona). In this array, index will relate directly to the channel ID.  
for i=1, 32 do  
 channel2source[i] = ""  
end  
  
-- Container to hold source references.  
local mySlots = {}   
  
-- A function to pre-load our sources, so we can play them quickly and efficiently.  
function preloadTracks(files)  
  
 -- Before loading, we need to ensure that we dispose of any pre-existing sources, so as not to build up memory.  
  
 -- Loop through our source array.  
 for i=1, #mySlots do  
  
 -- The channel2source array uses string values for the loadSound (myAudio) to prevent any non-closures.  
  
 local mySrc = tostring(mySlots[i])  
  
 -- Loop through our cross-reference to discover any matches.  
 for k=1, 32 do  
 if channel2source[k] == mySrc then  
 audio.stop( k )  
 audio.dispose( mySrc )  
 end  
 end  
 end  
  
 for i=1, #files do  
 mySlots[i] = audio.loadSound(files[i])  
 end  
end  
  
local myFiles = {"track1.mp3", "track2.mp3", "track3.mp3"}  
preloadTracks(myFiles)  
  
-- Somewhere else we make a request to actually play a track. (Perhaps in another lua file, or another function.)  
  
function playSlot(theSlot)  
 -- One of the secrets is that Corona returns two arguments when you play a track, the channel ID and the source, which we capture with te \_chnl and \_src respectively. Of course we can (and should) specify a channel in the audio.play([options]) area, and manage our channels directly... but another time.  
 local \_chnl, \_src = audio.play(mySlots[theSlot])  
  
 -- The channel2source array uses string values for the loadSound (myAudio) to prevent any non-closures.  
 -- Now we have a nice little setup to cross-reference which channel is playing which source:  
 channel2source[\_chnl] = tostring(\_src)  
end  
  
-- And now we can load tracks at any point in time. Since out preloadTracks() function properly disposes of any existing tracks, we no longer "build up" memory.  
local myFiles = {"track11.mp3", "track12.mp3", "track13.mp3"}  
preloadTracks(myFiles)  
  

Hopefully, some of these tid-bits of code might help future music app developers!

[import]uid: 616 topic_id: 21617 reply_id: 321617[/import]

Wow. Sounds awesome. I wish you the best of luck!

Naomi [import]uid: 67217 topic_id: 21617 reply_id: 85732[/import]

Very nice! Thanks for sharing. Couple of questions/follow ups:

Panning:
That looks a little funny to me. Maybe what’s going on is that the default OpenAL listener orientation is not facing the direction you think it should be. Probably setting the listener orientation would make setting the position for panning much more straight forward. (Imagine the listener is your head. The listener needs to turn so it is looking into the screen and its left ear points to the left side of the screen and the right ear points to the right side of the screen. Then you only need to pan across the x-value.)

Timing:
For the onComplete callback, what do you mean by backgrounding? Are you allowing audio to be played when the app is backgrounded? If so, I think technically you get the event when you return to the app, but yes, the Corona event system does effectively get shutdown durning backgrounding. (We hope to improve this someday.)

But if you are not allowing audio to be played when you leave the app, I thought Corona would pause the audio and resume when you come back. I would expect that when you resume, audio will resume and you will get the callback when it finishes. If this is not the case, I encourage you to file a bug.

Also, there is a similar discussion on getting more accurate timing here:
http://developer.anscamobile.com/forum/2011/08/15/drum-toy-0#comment-85323

[import]uid: 7563 topic_id: 21617 reply_id: 85743[/import]

WOW - this looks amazing! [import]uid: 52491 topic_id: 21617 reply_id: 85749[/import]

Ewing,
It was hard-pressed to find any solid documentation on panning – even in the openal and almixer docs. So I had to do a lot of trial and error. I’ve got a bruise on my forehead from the experience.

I suppose the best way to explain my understanding (how ever wrong it may be) is with a graphic:

In the graphic above (click here if you don’t see the graphic):

A. Changing just the X value causes the sound to only be heard either on the left or the right. And we assume that the further away, the quieter the sound…

B. However, from trial and error, I found that no matter how “far away” I set the X value, the sound was always at the same level. (Or the volume levels were getting affected, preventing me from having a fixed volume level – it’s been a while since I worked on this :slight_smile: Whatever the case, it just wasn’t working as easily and seemlessly as I had hoped.

C. When setting the Z and X value, the variances seemed extreme and not what I expected. And I assumed that what you see in graphic C was what was happening, that at the extremes, the speaker was going way far away.

D. So I had a hunch that I needed to “curve” the Z value in order to maintain a consistent distance.

This seems to have worked out quite nicely for me, since I only had to contend with one value for the overall pan – while maintaining a consistent volume level.

NOTE: Keeping the “pan” value between -1.5 and +1.5, prevents the cosine values from freaking out.

It’s all still a little murky. I’m still confused on what al.POSITION refers to, the “speaker” or the “person”?

Since OpenAL is set up for games, the whole 3d concept is kind of overkill for a linear app like Drone FX.

When it comes to simple audio / basic sound manipulation (and the general idea of panning in a linear 2d world), we have to reverse-engineer the 3d ObjectAL world back down to 2d “linear” thinking.

Maybe I over-thought the whole situation. But at the very least, it works, and doesn’t require any kind of manipulation of additional settings outside of al.Source(_src, al.POSITION, [pan_table])
Backgrounding

As for “backgrounding” – yes, I was trying to figure out a way to get the app to work during multi-tasking. (This is where the other bruise on my forehead came from.)

What I was hoping to do was sneak out under Lua down to the C level where openal/almixer resides, by pushing a sound down just before going into the background and having openal/almixer at the C level ping back to lua through the “onComplete” event.

Hoping, beyond hope that my little scheme would work. But it didn’t, and the Corona docs even clearly state the callbacks don’t work once the app gets put into background mode.

My scheme was based on the observance that a long audio file (like a song or something) will continue to play if audio.play() was called before the app goes to the background. But once the song is finished, that’s it, no more soup for you.

And that’s how / why i developed the “main loop” based on an audio file with the “onComplete” callback. Eh, just a few hours down the tube :slight_smile:

FYI: I ended up just keeping things simple and keeping UIApplicationExitsOnSuspend as the default value (not adding that flag to build.settings and just letting Corona deal with the default).

[import]uid: 616 topic_id: 21617 reply_id: 85787[/import]

Thanks for the great description of your though process and how you use the openAL APIs. I’m sure that will help others.

Just a few comments:

  1. Corona timers are based on the enterFrame event, which is typically 30 FPS, or 33.3 msec. This means if you try to set a timer to fire every 10 msec., it will fire every 33.3 msec. instead. If you set the FPS to 60, the time will be cut in half (every 16 msec.).

2). Setting UIApplicationExitsOnSuspend to false causes your app to suspend, which stops timers, display updates, and audio. The advantage of setting the flag to false is the app will generally resume (including any audio that was playing) when the app is started again. We currently don’t support backgrounding audio when suspended. [import]uid: 7559 topic_id: 21617 reply_id: 85846[/import]

Yes, despite OpenAL being essentially a long time industry standard, there isn’t a lot of documentation out there. That’s why I wrote a (comprehensive) book on it.

By the way, nice picture.

So I think a few things are off. First, I think z points the other direction.
Second, x-y-z positions should be completely linear, so it shouldn’t be curving like your picture D.

So in OpenAL, a “source” is an object that emits sound. You can have multiple sources in a game, each making noise. So these can be missiles, lasers, rocket engines, explosions, etc. Every one of these objects has some x-y-z position in space. That is what the OpenAL source position refers to. (In ALmixer/Corona, a source has a 1-to-1 mapping to a channel; So with 32-channels, you have 32-sources.
In OpenAL, there is also a “listener”. This is your head. There is only one listener allowed in OpenAL. The listener also has a x-y-z position in space.

OpenAL automatically calculates how to play the sound based on the relative positions of sources to the listener. So if the listener is a <0,0,0> (say center of the screen), and an explosion (source 1) is at position (to the left of the listener), the noise will come out the left speaker. If you move the explosion to , the noise will now come out of the right speaker.
But there are some additional things which are probably the reason things aren’t quite right for you.

First, you need to figure out which direction the coordinate system on your device is oriented. On a desktop, I *think* x points to the right, y goes up screen, and z comes at your head like an arrow shooting through you.

But on a device, particularly where you’ve changed to landscape mode, it might be that everything is rotated. I might guess x goes up the screen, y points to the left, and z still comes at you.
To compensate, there is another property of the listener in OpenAL called the listener “orientation”. Orientation is important because even though you might be standing in the center of the room, the direction you face determines which ear you hear sounds out of.

Once you set the orientation correctly, you should be able to pan simply by changing the x-value of the position from negative to positive and leave y and z at 0.0.

al.Source(_src, al.POSITION, {pan_value, 0, 0} )
One other detail is that OpenAL let’s you control both the reference difference and how the sound decays. The reference distance lets you change the scale, e.g. you go from 0 to 10, or 0 to 128, or 0 1,000,000, etc. Then based on the distance, you can specify the decay curve which can be linear, exponential, or inverse. This just means how fast does the sound volume drop as it moves away. For music panning, you probably want to specify linear.
All this I talk about in great detail in my book and in some detail in this video (somewhere like 1hr 15min to 1hr 30min in).
http://www.youtube.com/watch?v=6QQAzhwalPI

By the way, have you tried the ALExplorer demo program?
(Also, just to throw it out there, there could be a bug in my luaal.c binding. It has not been heavily tested. It is open source, so anybody can inspect it.)
[import]uid: 7563 topic_id: 21617 reply_id: 85883[/import]

ewing,
Thanks for the info. I did pick up an e-copy of your book, and watched the video on youtube. Great work.

I also did try the ALExplorer example before working on Drone FX, but it was all broken due to using widgets that are no longer supported.

After reading your previous response, and realizing that I hadn’t worked through all the technical details of the source and listener relationship, I decided to delve deep into OpenAL.

Using your book as a guide, I built out an app that provides a visual reference for a good portion of the 3D features in OpenAL. You can get the app, called “OpenAL Explorer”, on iTunes:

http://itunes.apple.com/us/app/openal-explorer/id504578957?mt=8

I designed it for the iPad, but it’ll work on an iPhone… but on an iPhone everything is quite small – so it’s best to run it on an iPad. Plus, if you’ve got an older iPhone, it’s rather doggish.

I also created a little “extension” for the audio class that simplifies pan and pitch:
http://developer.anscamobile.com/code/super-audio

And you’re right with respect to the Z direction… kinda… It all depends on the listener orientation and source direction! It was difficult to determine the “default” orientation(listener) and direction(source), so it’s best to just re-set those values before doing anything, that way you’ll know what’s what.

At any rate the experience with “OpenAL Explorer” was quite a challenge, but now, thanks to you and your book, I’ve got a firm grip on things!

[import]uid: 616 topic_id: 21617 reply_id: 89998[/import]

Wow that is very cool. Thanks for doing that! Next time I update my book website, I’ll have to link to that.

Quick thought on your panning though. I’m not sure what your use case for panning is, but for the plain old boring left-right panning you might get in music, I think you might be doing more work than you have to. Turning off distance attenuation effects and moving straight down the x-axis (left-right) should be sufficient.

But if you want attenuation enabled and want to stay equidistant from the listener at all times, your arc code is very cool.
[import]uid: 7563 topic_id: 21617 reply_id: 90369[/import]

Yeah, I tried setting:

al.DistanceModel(al.NONE)  

… then to pan:

-- val = -1 to +1  
al.Source(src, al.POSITION, val, 0, 0)  

… But what would happen is values < 0 would come out of the left speaker, and values > 0 out the right.

There is no “ramping” from 0 to +/-1 across the speakers.

You can test the theory out with OpenAL Explorer :slight_smile:

Unless I’m misunderstanding how to “Turn off distance attenuation effects”? Is there another setting other than al.DistanceModel(al.NONE)?

My feeble understanding leads me to believe that the distance model affects volume levels only. Not positioning?
[import]uid: 616 topic_id: 21617 reply_id: 90380[/import]

Sorry for the slow response. Been swamped putting out iPad HD fires.

The distance model is used in conjunction with the positions to compute the final output level.
There used to be a bug iOS where alDistanceModel(AL_NONE) didn’t work. I don’t remember if Apple ever fixed that.

Al a workaround, you can set the AL_ROLLOFF_FACTOR to 0 on a per-source basis.

You can also use relative positioning to fake disabling effects, though it doesn’t actually play too nice with velocity.

[import]uid: 7563 topic_id: 21617 reply_id: 95261[/import]