Sometimes it is desirable to play multiple audio files at the same time. There are at least a couple of different reasons for doing so.
HTML 5 Audio objects do not have any mixer capabilities and do not allow one object to play multiple files at the same time. Instead, one can set up multiple audio objects each playing a different file simultaneously. Naively, this is a simple solution but in practice there are some complications especially if some preciseness in the resulting audio is required.
To keep things simple, we just added the new capabilities to the previous sketch. Hence instead of balls and paddles that would be used in Pong, we just added buttons. "Source 1", "Source 2" and "Source 3" can be used to simulate a Pong style game except the player must click the buttons to make the sound. The "Chord" button is designed to play three files simultaneously.
We will use the existing coding for the background music. Taking advantage of the arrays setup in the Part 5, a longer file was added so one can see the response when it is loaded. In real applications, many of the controls set up in the previous tutorials could be eliminated.
There are 4 new buttons (3 sound buttons and the chord button) which play sounds independently of the background music and each other. The new "Display" on/off button will be discussed later.
While "pong game" requires only three new files, some times a file might be repeated before a previous invocation has completed. We are not really sure how many sounds might be played simultaneously. So following the lead in Part 5, we will use an array of audio objects. Files are loaded into an currently unused array element when it is to be played. We will be able to assign a particular source file to a different element in that array as many times as needed. When playing a file is completed, the audio element will be made available for use by any of the files, as needed. The array is relatively simple to set up.
The array is larger than the number of files so a given file can be played multiple times simultaneously. Unfortunately there may be a problem. Because files are played in a asynchronous manner, one cannot guarantee that files will be started at exactly when requested. This is particularly a problem when exact timing is essential or when trying to play chords. This may be evident when the "Chord" button is clicked. Part of the time only 2 of the 3 notes in the chord will play. This may be caused by the fact that the code may look for an available Audio element before the previously selected element actually starts playing and it appears to be still available. A solution for this will be attempted in the next tutorial.
If we are going to play short files repeatedly, it doesn't really make sense to check for the file type that will be used every time we play a file. So we will determine the desired file type once and for all in the setup method. A new method, determineFileExt, discovers it for us.
266 String determineFileExt() { 267 // returns the file extension that will be used 268 String desiredExt; 269 Audio aud = new Audio(); 270 if (aud.canPlayType && aud.canPlayType("audio/ogg")) { 271 desiredExt = ".ogg"; 272 } else { 273 desiredExt = ".mp4"; 274 } 275 return desiredExt; 276 } // determineFileExt
A new variable, fileExt, is declared to hold the result of the method in line 28 and the method is called in line 60:
28 String fileExt; // extension being used by the browser: .ogg or .mp3 ... 60 fileExt = determineFileExt();
Lets continue by looking at the declaration of the array of Audio objects:
32 int numAudio = 10; 33 int lastAudio; 34 Audio[] audioArray = new Audio[numAudio];
The number of Audio objects in the array given by value of numAudio is arbitrary but should be at least as large as the maximum number of files that could be played simultaneously. Towards the end of this tutorial we demonstrate technique that will help you determine a satisfactory number. We will not create the individual Audio objects until they are needed.
In earlier versions of KISA, the Audio object audio was used in one song at a time fashion. We want to use it for the background music. To avoid having to start over, it will be equated to audioArray[0]. This simplifies the development of this tutorial because some of the routines can apply to both the background music and the new sounds. This is set up in line 90. In practice one might just want a separate Audio object for this purpose.
107 audioArray[0] = audio; 108 for (i = 1; i < numAudio; i++) 109 audioArray[i] = 0; // 0 => the element hasn't been used
Otherwise only very few additional changes are needed to use "audio" for the background music and still have all the functionality in the earlier KISA tutorials. In practice, many of the things done with the audio object might be unnecessary. One method had to be modified so that it would apply both to the original audio object and to the rest of the new audio objects. Another argument was added to the setSource method so that it could apply to any of the audioArray objects. Because the original audio object has been equated to audioArray[0], we can replace the original calls to setSource in setup, ended, and mouseClicked with ones that specify object "0".
113 setSource(0, files[currentFile]); // pick the initial audio file ... 176 void setSource(int num, String url) { ... 222 setRadioButton(fileBtn, currentFile); ... 332 setSource(0, files[currentFile]);
In addition to declaring the Audio array, we need to add some new files and buttons. The new files are declared in lines 19 to 26 and the new buttons in 39-40. To locate these buttons on the canvas, a couple of new variables are also declared:
19 String[] soundFiles = {"http://brinkje.com/KISA_Sounds/ding", 20 "http://brinkje.com/KISA_Sounds/FavSound", 21 "http://brinkje.com/KISA_Sounds/truck_horn"}; 22 int numSoundFiles = soundFiles.length; 23 String[] chordFiles = {"http://brinkje.com/KISA_Sounds/jenHum01", 24 "http://brinkje.com/KISA_Sounds/jenHum02", 25 "http://brinkje.com/KISA_Sounds/jenHum03"}; 26 int numChordFiles = chordFiles.length; ... 40 CircleButton[] soundFilesBtn = new CircleButton[numSoundFiles]; 41 CircleButton chordBtn; 47 int xSoundFiles = 388; // Location of first sound file 48 int ySoundFiles = 195;
The buttons are created in setup and drawn in draw. Before drawing the buttons, a box that encloses them is drawn. The code is enclosed in a try/catch structure just in case the there is an attempt to draw them before they are created.
93 for (i = 0; i < numSoundFiles; i++) { 94 soundFilesBtn[i] = new CircleButton(xSoundFiles, ySoundFiles, 95 "Sound " + i); 96 ySoundFiles += 25; 97 } 98 99 ySoundFiles += 25; // leave a blank line 100 chordBtn = new CircleButton(xSoundFiles, ySoundFiles, "Chord"); 101 ySoundFiles += 25; ... 153 try { 154 fill(#DDDD99); 155 rect(xSoundFiles - 18, ySoundFiles - 143, 119, 133); 156 for (i = 0; i < numSoundFiles; i++) { 157 if (soundFilesBtn[i] != null) 158 soundFilesBtn[i].draw(); 159 } 160 chordBtn.draw(); ... 164 } catch (e) { 165 // ignore 166 }
A new function load picks the first available audio element and loads the specified file into it. An audioArray element is available if it has never been used ("=0") or if it has finished (ended) playing the previous source file. The method returns the subscript of the object selected.
229 int load(String fileName) { 230 // Determines the first available audioArray object, 231 // loads the file into it and returns the subscript of that 232 // Audio object. The audio element must be created if it has 233 // not been used before (=0). If it has been used and the play 234 // has been completed, then audioArray[i].ended will be true. 235 int i; 236 // find the first audio player that is not in use 237 for (i = 1; i < numAudio; i++) 238 { 239 if (audioArray[i] == 0 // audioArray[i] has not been used 240 || audioArray[i].ended) // audioArray[i] is finished playing 241 { 242 // if needed, initialize the audioArray element 243 if (audioArray[i] == 0) { 244 audioArray[i] = new Audio(); 245 audioArray[i].addEventListener("playing", playing, false); 246 audioArray[i].volume = audio.volume; 247 } 248 // set the source unless audio player already is using the file 249 if (audioArray[i].src != fileName + fileExt) { 250 setSource(i, fileName); 251 } 252 // return the player number 253 return i; 254 } 255 } 256 return -1; // All the audioArray elements are in use 257 } // load
Notice that the for loop begins with i = 1 because audioArray[0] is reserved for the background music. There are some alternatives to just picking the first available audioArray object. A Javascript writer suggests avoiding the search for the first available audio object and just assigning the audio objects in a round robin manner. This should work as long as the sound files are about the same length. Another technique would be to first check to see if one of the available audioArray objects already was using the desired source file. Picking such an object could avoid a delay in loading the source into the object.
It important to observe that the load method does not actually load the file. Instead it initiates the loading operation and returns immediately. This loading is skipped if the selected Audio object used the same file in its last use.
After loading the file, it can be played by the following simple method:
259 void play(int num) { 260 if (num >= 0 && num < numAudio) { 261 audioArray[num].play(); 262 lastAudio = num; // optional 263 } 264 } // play
We are almost done with the required code. All that is needed is to recognize the new buttons in the mouseClicked method. The first three buttons play the file immediately when it is loaded. However, for the chordBtn we load all the files before playing them in hopes that they start playing in at the "same time". Because of the loop handling in the sound buttons, the chord button is handled first.
312 } else if (chordBtn.isOver()) { 313 for (i = 0; i < numChordFiles; i++) 314 chordPlayer[i] = load(chordFiles[i]); 315 /*** don't do this - it stall the program! *** 316 while(audioArray[chordPlayer[0]].readyState != 4 317 || audioArray[[chordPlayer[1]].readyState != 4 318 || audioArray[[chordPlayer[2]].readyState != 4) { 319 // waste time 320 } 321 */ 322 for (i = 0; i < numChordFiles; i++) 323 play(chordPlayer[i]); ... 327 } else { ... 336 for (i = 0; i < numSoundFiles; i++) { 337 if (soundFilesBtn[i].isOver()) { 338 play(load(soundFiles[i])) 339 } 340 } 341 }
As discussed earlier, the code for the chord does not always work correctly. For example, sometimes the first file does not actually play. Why? It appears that the problem occurs because of the asynchronous nature of starting a load. It appears that the search for the first available audioArray object for the second file begins before the actual loading of the first note begins and hence the object selected for the first file reports that it is still "ended" and still available. Thus the code picks the same element for playing the second file.
The "obvious" solution was to waste some time using the loop in lines 293 - 297. But that resulted in completely stalling the program. Why? It appears the computer was so busy going around the loop, it never started the loading the file! I decided to just comment out the bad code to avoid repeating the mistake some other time.
There are just a few additional notes about KISA 6. First one should be able to adjust the volume of the sound effects as well as the background music. The coding for the volume buttons has been changed and a new method was added to adjust the volume on all the audioArray objects.
294 } else if (volumeUpBtn.isOver()) { 295 v = audio.volume + 0.1; 296 adjustVolume(v); 297 } else if (volumeDownBtn.isOver()) { 298 v = audio.volume - 0.1; 299 adjustVolume(v); ... 351 void adjustVolume(double v) { 352 int i; 353 v = constrain(v, 0, 1); 354 for (i = 0; i < numAudio; i++) { 355 if (audioArray[i] != 0) 356 audioArray[i].volume = v; 357 } 358 } // adjustVolume
To illustrate how files are loaded, the preload attribute was used to delay loading the background music until it is played. The default for "preload" is "auto" which means automatically start loading the file as soon as possible and is used in all the other audioArray objects. In between these two options, there is a third option "metadata" which downloads a minimal amount of information about the file. In practice, these options may not make as much difference as one would expect (after the first use).
69 audio.preload = "none"; // wait until file is played to load it
One question an implementer might ask is "How can I tell how many audioArray objects does my program need? To help answer this question some "optional" code was added to help you tell how many objects are being used. A new variable was added to keep track of the last audio object used. This code would probably be removed in the production version.
49 lastAudio = -1; // subscript of last audio element used // optional ... 161 if (lastAudio > 0) // optional 162 text("Last audio player: " + lastAudio, // optional 163 xSoundFiles - 10, ySoundFiles - 47); // optional ... 262 lastAudio = num; // optional
Lets finally discuss the "Display on/off" toggle button. Turning this on displays information about the status of each of the audioArray elements that have been declared and used. The following information is provided for each of the audio elements.
The last three of these items are presented graphically. The location is shown with a moving line and the ranges are shown with bar graphs.
To provide some additional information about the status of the element, the color of the bars in the graph change. The color of the buffered bar reflects the network state. The color of the seekable bar reflects the current ready state. The meaning of the different colors is shown in the table.
The techniques for handling simultaneous audio files used in this tutorial are probably adequate in many situations where there may be some background music, and in addition, random sounds are played such as in some games. Unfortunately they are not adequate when more precision is needed or when multiple files are started simultaneously. The next tutorial will discuss a more sophisticated way of selecting the audioArray objects. Testing indicates it resolves several of the issues with the technique in this tutorial. In particular, it solves the problem of not all the players playing when the chord button is clicked. Part 8 will look at a technique to synchronize multiple Audio players.
< Previous (KISA 5)
Next (KISA 7) >
1 // KISA 2 // Keep It Simple Audio version 6 3 // James Brink brinkje@plu.edu 4 // 3/28/12 4/12/12 6/18/12 11/5/14 5 6 // The constants can be modified. 7 int width = 500; // Width of canvas 8 int height = 325; // Height of canvas 9 10 // It is assumed that both .ogg and .mp3 versions of the files are available 11 // in the same folder. The file names must be complete URLs except for the 12 // ending .ogg or .mp3 which must be omitted. It will be added as needed for 13 // the browser. Use the prefix "file:///" or "http:// as appropriate. 14 String[] files = {"http://brinkje.com/KISA_Sounds/groove", 15 "http://brinkje.com/KISA_Sounds/jingle", 16 "http://brinkje.com/KISA_Sounds/marcus_kellis_theme"}; 17 int numFiles = files.length; 18 int currentFile = 0; // current file for audio = audioArray[0] 19 String[] soundFiles = {"http://brinkje.com/KISA_Sounds/ding", 20 "http://brinkje.com/KISA_Sounds/FavSound", 21 "http://brinkje.com/KISA_Sounds/truck_horn"}; 22 int numSoundFiles = soundFiles.length; 23 String[] chordFiles = {"http://brinkje.com/KISA_Sounds/jenHum01", 24 "http://brinkje.com/KISA_Sounds/jenHum02", 25 "http://brinkje.com/KISA_Sounds/jenHum03"}; 26 int numChordFiles = chordFiles.length; 27 28 String fileExt; // extension being used by the browser: .ogg or .mp3 29 30 // Global variables 31 Audio audio = new Audio(); 32 int numAudio = 10; 33 int lastAudio; 34 Audio[] audioArray = new Audio[numAudio]; 35 36 int time; 37 String audioStatus; 38 CircleButton playBtn, pauseBtn, stopBtn; 39 CircleButton volumeDownBtn, volumeUpBtn, timeDownBtn, timeUpBtn; 40 CircleButton[] soundFilesBtn = new CircleButton[numSoundFiles]; 41 CircleButton chordBtn; 42 ToggleButton[] fileBtn = new ToggleButton[numFiles]; 43 ToggleButton loopBtn, autoplayBtn; 44 45 boolean repeatLoop = false; // Should the player loop? 46 47 int xSoundFiles = 388; // Location of first sound file 48 int ySoundFiles = 195; 49 lastAudio = -1; // subscript of last audio element used // optional 50 51 // display items 52 Display dis = new Display(audioArray, width, height, false); 53 ToggleButton displayOnBtn; 54 55 void setup() { 56 int i, y; 57 // Setup the sketch 58 size(width, height); 59 smooth(); 60 fileExt = determineFileExt(); 61 62 if (audio == null) { 63 noLoop(); 64 return; 65 } 66 frameRate(20); 67 68 // audio info and listeners 69 audio.preload = "none"; // wait until file is played to load it 70 audio.addEventListener("loadstart", loadStart, false); 71 audio.addEventListener("playing", playing, false); 72 audio.addEventListener("pause", pause, false); 73 audio.addEventListener("ended", ended, false); 74 audio.addEventListener("error", error, false); 75 76 // Setup buttons 77 playBtn = new CircleButton(130, 145, "Play"); 78 pauseBtn = new CircleButton(230, 145, "Pause"); 79 stopBtn = new CircleButton(330, 145, "Stop"); 80 volumeDownBtn = new CircleButton(165, 170, "Volume down"); 81 volumeUpBtn = new CircleButton(270, 170, "Volume up"); 82 timeDownBtn = new CircleButton(165, 195, "Time down"); 83 timeUpBtn = new CircleButton(270, 195, "Time up"); 84 loopBtn = new ToggleButton(165, 220, "Loop", false); 85 autoplayBtn = new ToggleButton(270, 220, "Autoplay", false); 86 y = 245; 87 for (i = 0; i < numFiles; i++) { 88 fileBtn[i] = new ToggleButton(20, y, files[i], false); 89 y += 25; 90 } 91 fileBtn[currentFile].setState(true); 92 93 for (i = 0; i < numSoundFiles; i++) { 94 soundFilesBtn[i] = new CircleButton(xSoundFiles, ySoundFiles, 95 "Sound " + i); 96 ySoundFiles += 25; 97 } 98 99 ySoundFiles += 25; // leave a blank line 100 chordBtn = new CircleButton(xSoundFiles, ySoundFiles, "Chord"); 101 ySoundFiles += 25; 102 103 // display 104 displayOnBtn = new ToggleButton(20, 20, "Display on/off", false); 105 106 // setup the audio array 107 audioArray[0] = audio; 108 for (i = 1; i < numAudio; i++) 109 audioArray[i] = 0; // 0 => the element hasn't been used 110 111 // other 112 audioStatus = ""; 113 setSource(0, files[currentFile]); // pick the initial audio file 114 audioStatus = "Waiting"; // because preload is "metadata" 115 } // setup 116 117 void draw() { 118 // Draws the sketch on the canvas 119 120 background(#FFFFAA); 121 fill(#000000); 122 if (audio == null) { 123 text("Your browser does not handle the HTML 5 audio tag. You ", 20, 30); 124 text("may want to upgrade your browser to the current version.", 20, 60); 125 return; 126 } 127 128 textAlign(CENTER); 129 text("KISA 6 with Display", width/2, 30); 130 text("Source file: " + audio.src, width/2, 60); 131 text("Status: " + audioStatus, width/2, 80); 132 text("Current time: " + round(audio.currentTime) 133 + " sec. Length: " + round(audio.duration)+ " sec.", width/2, 100); 134 text("Volume (0 to 1): " + round(10 * audio.volume)/10.0, width/2, 120); 135 136 try { 137 playBtn.draw(); 138 pauseBtn.draw(); 139 stopBtn.draw(); 140 volumeDownBtn.draw(); 141 volumeUpBtn.draw(); 142 timeDownBtn.draw(); 143 timeUpBtn.draw(); 144 loopBtn.draw(); 145 autoplayBtn.draw(); 146 for (i = 0; i < numFiles; i++) { 147 fileBtn[i].draw(); 148 } 149 } catch (e) { 150 // ignore 151 } 152 // draw sound and chord buttons 153 try { 154 fill(#DDDD99); 155 rect(xSoundFiles - 18, ySoundFiles - 143, 119, 133); 156 for (i = 0; i < numSoundFiles; i++) { 157 if (soundFilesBtn[i] != null) 158 soundFilesBtn[i].draw(); 159 } 160 chordBtn.draw(); 161 if (lastAudio > 0) // optional 162 text("Last audio player: " + lastAudio, // optional 163 xSoundFiles - 10, ySoundFiles - 47); // optional 164 } catch (e) { 165 // ignore 166 } 167 try { 168 // draw the display items 169 dis.draw(); 170 displayOnBtn.draw(); 171 } catch (e) { 172 // ignore 173 } 174 } // draw 175 176 void setSource(int num, String url) { 177 // Called to set the source file. Do not include the file extension 178 // in the url. It will be added here. "num" specifies subscript of 179 // the audio object that will be used. 180 if (num >= 0 && num < numAudio) { 181 audioArray[num].setAttribute("src", url + fileExt); 182 if (num == 0) 183 audioStatus = "File selected"; 184 } 185 } // setSource 186 187 void stop() { 188 // Called stop playing the file by pausing it and setting the 189 // time back to 0; 190 audio.pause(); 191 audio.currentTime = 0; 192 audioStatus = "Stopped"; 193 } // stop 194 195 void loadStart() { 196 // LoadStart event processing 197 audioStatus = "Loading"; 198 } // loadStart 199 200 void playing() { 201 // Playing event processing 202 audioStatus = "Playing"; 203 } // playing 204 205 void pause() { 206 // Pause event processing. 207 // There is no stop event but a "stop" causes a pause. This 208 // method checks to see if the current time = 0. If so it assumes 209 // stopped 210 if (audio.currentTime == 0) 211 audioStatus = "Stopped"; 212 else 213 audioStatus = "Paused"; 214 } // pause 215 216 void ended() { 217 int j; 218 // Ending event processing 219 if (repeatLoop) { 220 currentFile = (currentFile+1) % numFiles; 221 setSource(0, files[currentFile]); 222 setRadioButton(fileBtn, currentFile); 223 audio.play(); 224 } else { 225 audioStatus = "Finished playing"; 226 } 227 } // ended 228 229 int load(String fileName) { 230 // Determines the first available audioArray object, 231 // loads the file into it and returns the subscript of that 232 // Audio object. The audio element must be created if it has 233 // not been used before (=0). If it has been used and the play 234 // has been completed, then audioArray[i].ended will be true. 235 int i; 236 // find the first audio player that is not in use 237 for (i = 1; i < numAudio; i++) 238 { 239 if (audioArray[i] == 0 // audioArray[i] has not been used 240 || audioArray[i].ended) // audioArray[i] is finished playing 241 { 242 // if needed, initialize the audioArray element 243 if (audioArray[i] == 0) { 244 audioArray[i] = new Audio(); 245 audioArray[i].addEventListener("playing", playing, false); 246 audioArray[i].volume = audio.volume; 247 } 248 // set the source unless audio player already is using the file 249 if (audioArray[i].src != fileName + fileExt) { 250 setSource(i, fileName); 251 } 252 // return the player number 253 return i; 254 } 255 } 256 return -1; // All the audioArray elements are in use 257 } // load 258 259 void play(int num) { 260 if (num >= 0 && num < numAudio) { 261 audioArray[num].play(); 262 lastAudio = num; // optional 263 } 264 } // play 265 266 String determineFileExt() { 267 // returns the file extension that will be used 268 String desiredExt; 269 Audio aud = new Audio(); 270 if (aud.canPlayType && aud.canPlayType("audio/ogg")) { 271 desiredExt = ".ogg"; 272 } else { 273 desiredExt = ".mp4"; 274 } 275 return desiredExt; 276 } // determineFileExt 277 278 void error() { 279 // error event processing 280 audioStatus = "Error"; 281 } // error 282 283 void mouseClicked() { 284 // Mouse clicked event processing 285 double v, t; 286 int i; 287 int[] chordPlayer = new int[numChordFiles]; 288 if (playBtn.isOver()) { 289 audio.play(); 290 } else if (pauseBtn.isOver()) { 291 audio.pause(); 292 } else if (stopBtn.isOver()) { 293 stop(); 294 } else if (volumeUpBtn.isOver()) { 295 v = audio.volume + 0.1; 296 adjustVolume(v); 297 } else if (volumeDownBtn.isOver()) { 298 v = audio.volume - 0.1; 299 adjustVolume(v); 300 } else if (timeUpBtn.isOver()) { 301 t = audio.currentTime + 0.1 * audio.duration; 302 audio.currentTime = constrain(t, 0, audio.duration); 303 } else if (timeDownBtn.isOver()) { 304 t = audio.currentTime - 0.1 * audio.duration; 305 audio.currentTime = constrain(t, 0, audio.duration); 306 } else if (loopBtn.isOver()) { 307 loopBtn.toggleState(); 308 repeatLoop = loopBtn.getState(); 309 } else if (autoplayBtn.isOver()) { 310 autoplayBtn.toggleState(); 311 audio.autoplay = autoplayBtn.getState(); 312 } else if (chordBtn.isOver()) { 313 for (i = 0; i < numChordFiles; i++) 314 chordPlayer[i] = load(chordFiles[i]); 315 /*** don't do this - it stall the program! *** 316 while(audioArray[chordPlayer[0]].readyState != 4 317 || audioArray[[chordPlayer[1]].readyState != 4 318 || audioArray[[chordPlayer[2]].readyState != 4) { 319 // waste time 320 } 321 */ 322 for (i = 0; i < numChordFiles; i++) 323 play(chordPlayer[i]); 324 } else if (displayOnBtn.isOver()) { 325 displayOnBtn.toggleState(); 326 dis.turnDisplayOn(displayOnBtn.getState()); 327 } else { 328 for (i = 0; i < numFiles; i++) { 329 if (fileBtn[i].isOver()) { 330 setRadioButton(fileBtn, i); 331 currentFile = i; 332 setSource(0, files[currentFile]); 333 return; 334 } 335 } 336 for (i = 0; i < numSoundFiles; i++) { 337 if (soundFilesBtn[i].isOver()) { 338 play(load(soundFiles[i])) 339 } 340 } 341 } 342 } // mouseClicked 343 344 void setRadioButton(ToggleButton[] btnArray, int i) { 345 int j; 346 for (j = 0; j < numFiles; j++) 347 btnArray[j].setState(false); 348 btnArray[i].setState(true); 349 } // setRadioButton 350 351 void adjustVolume(double v) { 352 int i; 353 v = constrain(v, 0, 1); 354 for (i = 0; i < numAudio; i++) { 355 if (audioArray[i] != 0) 356 audioArray[i].volume = v; 357 } 358 } // adjustVolume 359 360 361
[+/-] The CircleButton.pde file
Updated 11/6/14