1
2
3 import pygame
4 import os
5 import time
6 from operator import itemgetter
7 import threading
8
9
10
11 NUM_SIMULTANEOUS_SOUNDS = 8;
12 NUM_OF_CACHED_SOUNDS = 500;
13
15 '''
16 Plays up to NUM_SIMULTANEOUS_SOUNDS .wav/.ogg files simultaneously. Allows pause/unpause on
17 any of these sounds. (.ogg untested)
18
19 Theory of operation: The class uses the pygame module. This module involves Sound instances
20 and Channel instances. A Sound is created from a .wav or .ogg file, and can play on one or
21 more Channel instances.
22
23 All channels can simultaneously play, one Sound per channel. The
24 number of channels can be set via the module-global constant
25 NUM_SIMULTANEOUS_SOUNDS. Default is 8. Channels and Sounds are managed
26 by pygame.mixer.
27
28 This class uses parameter polymorphism to unify all these concepts as
29 much as possible. The only public methods are
30 <code>play(), stop(), pause(), unpause(),set_volume(),
31 get_volume()</code>. Each of these methods takes either the path to
32 a sound file, a Sound instances, or a Channel instance. The
33 SoundPlayer takes care of creating Sound instances by loading them
34 from files, caching sounds, and tracking which Sounds are playing on
35 which Channel at any time. Callers of these methods need to deal only
36 with the sound file paths. These paths, when passed to, say, the
37 pause() method, will do the right thing; Sound instances are cached,
38 so they will not be loaded repeatedly.
39
40 Note that this wonderful collapsing of complex underlying pygame
41 concepts comes at a price in code ugliness. Since Python does not
42 have parameter based polymorphism, methods must figure out incoming
43 parameter types via duck typing methods: Treat the parameters as some
44 type and see whether an error occurs. Terrible, but the 'pythonic' way.
45
46
47 As background: The pygame API provides methods three entities:
48 Mixer, Channel, and Sound. The main methods are:
49
50 1. Mixer:
51 - stop()
52 - pause()
53 - unpause()
54 - get_num_channels()
55 - set_num_channels()
56 2. Channel:
57 - play()
58 - stop()
59 - pause()
60 - unpause()
61 3. Sound:
62 - play()
63 - stop()
64 - pause()
65 - unpause()
66 '''
67
68
69 singletonInstanceRunning = False;
70
71 supportedFormats = ['ogg', 'wav'];
72
73
74
75
76
90
91
92
93
94
95 - def play(self, whatToPlay, blockTillDone=False, volume=None):
96 '''
97 Play a file, or an already loaded Sound instance.
98 Offers choice of blocking return until the sound is finished, or
99 returning immediately.
100
101 @param whatToPlay: Full path to .wav or .ogg file, or Sound instance.
102 @type whatToPlay: {string | Sound}
103 @param blockTillDone: True to delay return until sound is done playing.
104 @type blockTillDone: boolean
105 @param volume: Volume to play at. Float between 0.0 and 1.0. None: use current volume.
106 @type volume: float
107 @return: The sound instance.
108 @raise IOError: if given sound file path does not exist, or some other playback error occurred.
109 @raise ValueError: if given volume is not between 0.0 and 1.0
110 @raise TypeError: if whatToPlay is not a filename (string), or Sound instance.
111 '''
112
113 if volume is None:
114 volume = self.globalVolume;
115 elif (volume is not None) and ( (volume < 0.0) or (volume > 1.0) ):
116 raise ValueError("Volume must be between 0.0 and 1.0");
117
118 with self.lock:
119 self.cleanupSoundChannelBindings();
120
121 try:
122
123 (sound, channel) = self.playFile(whatToPlay, volume);
124
125 if channel is not None:
126 self.addSoundToChannelBinding(sound, channel);
127 self.lastUsedTime[sound] = time.time();
128 else:
129 raise IOError("Could not play sound '" + str(whatToPlay) + "'");
130 except TypeError:
131
132 try:
133
134 sound = whatToPlay;
135 channel = sound.play();
136 self.addSoundToChannelBinding(sound, channel);
137 self.lastUsedTime[sound] = time.time();
138 except AttributeError:
139
140 raise TypeError("Play method takes the path to a sound file, or a Sound instance. Instead received:" +
141 str(whatToPlay));
142
143
144
145
146 if blockTillDone:
147 self.waitForSoundDone(sound)
148 with self.lock:
149
150 self.cleanupSoundChannelBindings();
151 return sound;
152
153
154
155
156
157 - def stop(self, whatToStop=None):
158 '''
159 Stop either all currently playing sounds, or a particular sound.
160 The sound to stop (parameter whatToStop) may be specified as a
161 the full-path filename of the respective .wav/.ogg file, as a
162 Sound instance, or as a Channel instance.
163 @param whatToStop: If None, top all currently playing sounds. If any a
164 Sound instance, stop this sound on all channels on which
165 it might currently be playing. If a Channel instance,
166 stop only whatever is currently playing on this channel.
167 @type whatToStop: {NoneType | string | Sound | Channel}
168 '''
169 with self.lock:
170 try:
171 if whatToStop is None:
172 pygame.mixer.stop();
173 return;
174 try:
175 self.stopFile(whatToStop);
176 return;
177 except TypeError:
178 pass
179
180
181
182 try:
183 whatToStop.stop();
184 except:
185 raise TypeError("Parameter whatToStop must be a filename, Sound instance, or Channel instance.")
186 finally:
187 self.cleanupSoundChannelBindings();
188
189
190
191
192
193 - def pause(self, whatToPause=None):
194 '''
195 Pause either all currently playing sounds, or a particular sound.
196 The sound to pause (parameter whatToStop) may be specified as a
197 the full-path filename of the respective .wav/.ogg file, as a
198 Sound instance, or as a Channel instance.
199 @param whatToPause: If None, top all currently playing sounds. If any a
200 Sound instance, pause this sound on all channels on which
201 it might currently be playing. If a Channel or array of
202 channel instances, pause whatever is currently playing
203 on the given channel(s).
204 @type whatToPause: {NoneType | string | Sound | Channel | [Channel]}
205 @raise TypeError: if whatToPause is of illegal type.
206 '''
207
208 with self.lock:
209 self.cleanupSoundChannelBindings();
210
211 if whatToPause is None:
212
213 pygame.mixer.pause();
214 return
215
216 try:
217
218 if self.pauseFile(whatToPause) is not None:
219
220
221
222 return;
223 except TypeError:
224
225 pass;
226
227
228
229 channels = self.getChannelsFromSound(whatToPause);
230 if channels is None:
231
232
233
234 channels = whatToPause;
235 try:
236 len(channels)
237 except TypeError:
238 channels = [whatToPause];
239
240
241 try:
242 for channel in channels:
243 if channel.get_busy():
244 channel.pause();
245 except:
246
247
248 raise TypeError("Parameter whatToPause must be a filename, Sound instance, Channel instance, or iterable of channel instances.")
249 return;
250
251
252
253
254
255 - def unpause(self, whatToUnPause=None):
256 '''
257 Unpause either all currently playing sounds, or a particular sound.
258 The sound to unpause (parameter whatToStop) may be specified as a
259 the full-path filename of the respective .wav/.ogg file, as a
260 Sound instance, or as a Channel instance.
261 @param whatToUnPause: If None, unpause all currently playing sounds. If whatToUnPause
262 is a Sound instance, pause this sound on all channels on which
263 it might currently be playing. If whatToUnPause is a Channel or array of
264 channel instances, pause whatever is currently playing
265 on the given channel(s).
266 @type whatToUnPause: {NoneType | string | Sound | Channel}
267 '''
268
269 with self.lock:
270 self.cleanupSoundChannelBindings();
271
272 if whatToUnPause is None:
273
274 pygame.mixer.unpause();
275 return
276
277 try:
278
279 if self.unpauseFile(whatToUnPause) is not None:
280
281
282
283 return;
284 except TypeError:
285
286 pass;
287
288
289
290 channels = self.getChannelsFromSound(whatToUnPause);
291 if channels is None:
292
293
294
295 channels = whatToUnPause;
296 try:
297 len(channels)
298 except TypeError:
299 channels = [whatToUnPause];
300
301 try:
302 for channel in channels:
303 if channel.get_busy():
304 channel.unpause();
305 except:
306
307
308 raise TypeError("Parameter whatToUnPause must be a filename, Sound instance, or Channel instance.")
309 return;
310
311
312
313
314
316 '''
317 Set sound volume for a particular sound or channel.
318 The entity for which to set the volume (i.e. whatToSetVolFor)
319 may be the full-path filename of a .wav/.ogg file, a Sound instance,
320 or a Channel instance. Here is the interaction between settings
321 of Sound vs. Channel volume:
322 - sound.set_volume(0.9) # Now plays at 90% of full volume.
323 - sound.set_volume(0.6) # Now plays at 60% (previous value replaced).
324 - channel.set_volume(0.5) # Now plays at 30% (0.6 * 0.5).
325 Passing in a filename will load the file, and set the volume of the
326 corresponding Sound.
327 @param volume: Value between 0.0 and 1.0.
328 @type volume: float
329 @param whatToSetVolFor: The soundfile, Sound, or Channel instance whose volume to set.
330 If None, sets volume for all sounds. (This setting is overridden
331 by volume settings provided in calls to the play() method.
332 @type whatToSetVolFor: {NoneType | string | Sound | Channel}
333 @raise OSError: if given filename that does not exist.
334 '''
335
336 if (volume is not None) and ( (volume < 0.0) or (volume > 1.0) ):
337 raise ValueError("Sound volume must be between 0.0 and 1.0. Was " + str(volume));
338
339 try:
340
341
342
343
344
345 wasUnlocked = self.lock.acquire(False);
346
347 if whatToSetVolFor is None:
348 self.globalVolume = volume;
349 return;
350
351
352
353 try:
354 whatToSetVolFor.set_volume(volume);
355 return;
356 except:
357
358 pass
359
360
361
362 sound = self.getSoundFromFileName(whatToSetVolFor);
363 if sound is not None:
364
365 sound.set_volume(volume);
366 return;
367 else:
368
369 sound = self.loadSound(whatToSetVolFor);
370 sound.set_volume(volume);
371 return;
372 except:
373 pass;
374 finally:
375
376
377 if wasUnlocked:
378 self.lock.release();
379
380
381
382
383
385 '''
386 Get sound volume for a particular sound or channel.
387 The entity for which to get the volume for (i.e. whatToSetVolFor)
388 may be the full-path filename of a .wav/.ogg file, a Sound instance,
389 or a Channel instance. Here is the interaction between settings
390 of Sound vs. Channel volume:
391 - sound.set_volume(0.9) # Now plays at 90% of full volume.
392 - sound.set_volume(0.6) # Now plays at 60% (previous value replaced).
393 - channel.set_volume(0.5) # Now plays at 30% (0.6 * 0.5).
394 Passing in a filename will load the file, and get the volume of the
395 corresponding Sound.
396
397 @param whatToGetVolFor: The soundfile, Sound, or Channel instance whose volume to get.
398 If None, return global value setting
399 @type whatToGetVolFor: {NoneType | string | Sound | Channel}
400 @raise OSError: if given filename that does not exist.
401 '''
402
403
404 try:
405
406
407
408
409
410 wasUnlocked = self.lock.acquire(False);
411
412
413 if whatToGetVolFor is None:
414 return self.globalVolume;
415
416
417
418 try:
419 return whatToGetVolFor.get_volume();
420 except:
421 pass
422
423
424 sound = self.getSoundFromFileName(whatToGetVolFor);
425 if sound is not None:
426 return sound.get_volume();
427 else:
428
429 sound = self.loadSound(whatToGetVolFor);
430 return sound.get_volume();
431 except:
432 pass
433 finally:
434
435
436 if wasUnlocked:
437 self.lock.release();
438
439
440
441
442
458
459
460
461
462
463
465 '''
466 Number of sounds to play simultaneously. Controlled by module variable NUM_SIMULTANEOUS_SOUNDS.
467 '''
468 return pygame.mixer.get_num_channels();
469
470
471
472
473
474
475
477 '''
478 Create a Sound instance from the given file. Cache the sound,
479 and initialize the LRU cache last-used time.
480 @param filename: Full path to sound file (.wav/.ogg)
481 @type filename: string
482 @raise IOError: if file does not exist.
483 '''
484 try:
485
486 return self.loadedSounds[filename];
487 except KeyError:
488 if not os.path.exists(filename):
489 raise IOError("Sound file " + str(filename) + " does not exist.");
490 sound = pygame.mixer.Sound(filename);
491
492 self.checkCacheStatus();
493 self.loadedSounds[filename] = sound;
494 self.loadedFilenames[sound] = filename;
495
496
497
498 self.lastUsedTime[sound] = time.time();
499 return sound;
500
501
502
503
504
506 '''
507 Ensures that number of cached sounds does not exceed
508 NUM_OF_CACHED_SOUNDS. If it does, half the cache is
509 emptied in order of least-recently-used first.
510 '''
511
512 if len(self.loadedSounds.keys()) < NUM_OF_CACHED_SOUNDS:
513 return;
514
515
516
517
518
519
520
521
522
523
524
525 soundTimePairList = sorted(self.lastUsedTime.items(), key=itemgetter(1));
526 for soundTimePair in soundTimePairList[:NUM_OF_CACHED_SOUNDS / 2]:
527 self.unloadSound(soundTimePair[0]);
528
529
530
531
532
534 '''
535 Remove Sound instance from cache and all other references.
536 @param sound: Sound instance to remove
537 @type sound: Sound
538 '''
539
540 soundFilename = None
541
542
543
544 channels = self.getChannelsFromSound(sound);
545 if channels is not None:
546 self.stop(sound);
547
548 try:
549 soundFilename = self.loadedFilenames[sound];
550 del self.loadedSounds[soundFilename];
551 except KeyError:
552
553
554 pass;
555
556 if soundFilename is not None:
557 try:
558 del self.loadedFilenames[sound]
559 except:
560 pass;
561
562 try:
563 del self.soundChannelBindings[sound];
564 except KeyError:
565
566
567 pass;
568 try:
569 del self.lastUsedTime[sound];
570 except KeyError:
571 pass;
572
573 return;
574
575
576
577
578
579 - def playFile(self, filename, volume=None):
580 '''
581 Private Method! Called by play();
582 Given a filename, load it into a Sound instance, if it is not
583 already cached. Set the volume, if given, and play. It is assumed
584 that the volume value, if given, has been verified by the caller
585 to be between 0.0 and 1.0.
586 @param filename: Name of sound file to play
587 @type filename: string
588 @param volume: Volume to play at: 0.0 to 1.0
589 @type volume: float
590 @return: Sound and channel instances
591 @rtype: (Sound, Channel)
592 @raise OSError: if file does not exist.
593 '''
594 if not isinstance(filename, basestring):
595 raise TypeError("Filename must be a string.")
596
597 sound = self.getSoundFromFileName(filename);
598 if sound is None:
599 sound = self.loadSound(filename);
600 if volume is not None:
601 self.setSoundVolume(volume, sound);
602 channel = sound.play();
603 return (sound, channel);
604
605
606
607
608
610 '''
611 Private Method! Called by stop();
612 Stops currently playing sound from the given filename.
613 Does nothing if this file is not currently playing.
614 @param filename: Filename from which the Sound instance to be stopped was created.
615 @type filename: string
616 @raise TypeError: filename is not a string.
617 '''
618
619 if not isinstance(filename, basestring):
620 raise TypeError("Filename must be a string.")
621 sound = self.getSoundFromFileName(filename);
622 if sound is None:
623
624 return None;
625 sound.stop();
626 return sound;
627
628
629
630
631
633 '''
634 Private Method! Called by pause();
635 Pauses currently playing sound from the given filename.
636 Does nothing if this file is not currently playing.
637 @param filename: Filename from which the Sound instance to be paused was created.
638 @type filename: string
639 @return: Sound instance
640 @raise TypeError: filename is not a string.
641 '''
642 if not isinstance(filename, basestring):
643 raise TypeError("Filename must be a string.")
644
645 sound = self.getSoundFromFileName(filename);
646 if sound is None:
647
648
649 return True;
650
651
652 channels = self.getChannelsFromSound(sound);
653 if channels is None:
654
655
656 return True;
657 for channel in channels:
658 channel.pause();
659 return sound;
660
661
662
663
664
666 '''
667 Unpauses currently paused sound from the given filename.
668 Does nothing if this file is not currently playing or paused.
669 @param filename: Filename from which the Sound instance to be unpaused was created.
670 @type filename: string
671 @return: Sound instance
672 @rtype: Sound
673 @raise TypeError: filename is not a string.
674 '''
675
676 if not isinstance(filename, basestring):
677 raise TypeError("Filename must be a string.")
678
679 sound = self.getSoundFromFileName(filename);
680 if sound is None:
681
682
683 return True;
684 channels = self.getChannelsFromSound(sound);
685 if channels is None:
686
687 return True;
688 for channel in channels:
689 channel.unpause();
690 return sound;
691
692
693
694
695
697 '''
698 Given a sound file path name, return the corresponding
699 Sound instance, if the file was loaded. Else return None.
700 @param filename: Filename from which the Sound instance was created.
701 @type filename: string
702 @return: Sound instance created from the given file. None if file not yet loaded.
703 @rtype: {Sound | NoneType}
704 '''
705 try:
706 return self.loadedSounds[filename]
707 except KeyError:
708
709 return None;
710
711
712
713
714
716 '''
717 Return Sound instance that is currently playing on the given Channel instance.
718 None if channel is inactive.
719 @param channel: Channel to be investigated.
720 @type channel: Channel
721 '''
722 if not channel.get_busy():
723 return None;
724 return channel.get_sound();
725
726
727
728
729
731 '''
732 Return all channels on which the given Sound instance
733 is currently playing.
734 @param sound: Sound instance to be hunted down.
735 @type sound: Sound
736 @return: Array of Channel instances, or None, if Sound is not currently playing on any channel.
737 @rtype: {[Channel] | NoneType}
738 '''
739 self.cleanupSoundChannelBindings();
740 try:
741 return self.soundChannelBindings[sound];
742 except (KeyError, TypeError):
743 return None;
744
745
746
747
748
750 '''
751 Register that a Sound is beginning to play on a given Channel.
752 @param sound: Sound to bind
753 @type sound: Sound
754 @param channel: Channel to bind to
755 @type channel: Channel
756 '''
757
758 self.cleanupSoundChannelBindings();
759
760
761 channels = self.getChannelsFromSound(sound);
762 if channels is not None:
763
764 channels.append(channel);
765 self.soundChannelBindings[sound] = channels;
766 else:
767 self.soundChannelBindings[sound] = [channel];
768
769
770
771
772
774 '''
775 Runs through the sound-to-channel bindings and removes
776 the entries of channels that are done playing.
777 '''
778 maybePlayingSounds = self.soundChannelBindings.keys();
779 for sound in maybePlayingSounds:
780
781 channels = self.soundChannelBindings[sound];
782
783 channelsCopy = list(channels);
784 for channel in channelsCopy:
785 if not channel.get_busy():
786 channels.remove(channel);
787 if len(channels) != 0:
788 self.soundChannelBindings[sound] = channels;
789 else:
790 del self.soundChannelBindings[sound];
791
792
793
794
795
797 '''
798 Block until sound is done playing on all channels
799 on which it is currently playing.
800 @param sound: Sound to monitor
801 @type sound: Sound
802 '''
803
804 channels = self.getChannelsFromSound(sound);
805 if channels is None:
806 return;
807 for channel in channels:
808 while channel.get_busy():
809 time.sleep(0.3);
810
811
812
813 if __name__ == '__main__':
814
815 import os
816
818 while channel.get_busy():
819 time.sleep(1);
820 if not channel.get_busy():
821 break;
822 player.pause(whatToPause);
823 time.sleep(3);
824 player.unpause(whatToPause);
825
826 testFileRooster = os.path.join(os.path.dirname(__file__), "../../sounds/rooster.wav");
827 testFileSeagulls = os.path.join(os.path.dirname(__file__), "../../sounds/seagulls_shore.wav");
828 testFileDrill = os.path.join(os.path.dirname(__file__), "../../sounds/drill.wav");
829 testFileMoo2 = os.path.join(os.path.dirname(__file__), "../../sounds/moo.wav");
830 testFileSteam = os.path.join(os.path.dirname(__file__), "../../sounds/steam.wav");
831
832 player = SoundPlayer();
833 print "Test one sound..."
834 channel = player.play(testFileRooster, blockTillDone=True);
835 print "Done test one sound..."
836 print "---------------"
837
838
839 print "Test pause/unpause all channels. Expect 1sec sound, 3sec pause, 1 second sound, 3sec pause, rest of sound...";
840
841
842 print "Done pause/unpause all channels.";
843 print "---------------"
844
845 print "Test pause/unpause by filename. Expect repeated cycles of 1sec sound, 3sec pause...";
846
847
848 print "Done test pause/unpause by filename.";
849 print "---------------"
850
851 print "Test two sounds after another: seagulls, then rooster..."
852
853
854
855
856 print "Done testing two sounds after another: seagulls, then rooster."
857 print "---------------"
858
859
860 print "Test pause/unpause while another sound is running. Expect repeated cycles of 1sec rooster and seagull sounds, 3sec, both sounds again for 1 sec,...";
861
862
863
864
865
866
867 print "Done test pause/unpause while another sound is running. Expect repeated cycles of 1sec rooster and seagull sounds, 3sec, both sounds again for 1 sec,...";
868 print "---------------"
869
870 print "Test attempt to play on more than 8 channels at once..."
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889 print "Done test attempt to play on more than 8 channels at once."
890 print "---------------"
891
892 print "Test pausing by filename when respective sound is playing on multiple channels..."
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948 print "Done test pausing by filename when respective sound is playing on multiple channels..."
949 print "---------------"
950
951 print "Test volume control. Hear rooster three times, once at full volume, then twice at the same lower volume"
952
953
954
955
956 print "Done test volume control. Hear rooster three times, once at full volume, then twice at the same lower volume"
957 print "---------------"
958
959 print "Test sound cache management; three second delay is normal."
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988 print "Done test sound cache management; three second delay is normal."
989 print "---------------"
990
991 print "Test singleton enforcement"
992 try:
993 player1 = SoundPlayer();
994 raise ValueError("Should have raised a RuntimeError.")
995 except RuntimeError:
996 pass;
997 print "Done testing singleton enforcement"
998 print "---------------"
999
1000 print "All done"
1001