2017-06-11 19:22:03 -04:00
|
|
|
'''
|
|
|
|
Implements all sound functionality
|
|
|
|
'''
|
2017-06-02 02:17:32 -04:00
|
|
|
import logging, os, hashlib, queue, time, psutil
|
2017-06-11 19:34:24 -04:00
|
|
|
from threading import Event, RLock
|
2017-06-02 02:17:32 -04:00
|
|
|
from exceptionThreading import ExceptionThread, async
|
2016-12-30 02:51:56 -05:00
|
|
|
from pygame import mixer
|
|
|
|
from subprocess import call
|
|
|
|
from collections import OrderedDict
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
class SoundEffect(mixer.Sound):
|
2017-06-11 19:22:03 -04:00
|
|
|
'''
|
|
|
|
Represents one discrete sound effect that can be called and played at will.
|
|
|
|
The clas wraps a mixer.Sound object which maps to one sound file on the
|
|
|
|
disk. In addition, it implements volume and/or loops. The former sets the
|
|
|
|
volume permanently (independent of the user-set volume) and the latter
|
|
|
|
defines how many times to play once called. Both are optional.
|
|
|
|
'''
|
2016-12-30 02:51:56 -05:00
|
|
|
def __init__(self, path, volume=None, loops=0):
|
|
|
|
super().__init__(path)
|
|
|
|
self.path = path
|
|
|
|
self.volume = volume
|
|
|
|
if volume:
|
|
|
|
self.set_volume(volume)
|
|
|
|
self.loops = loops
|
|
|
|
|
|
|
|
def play(self, loops=None):
|
|
|
|
loops = loops if loops else self.loops
|
|
|
|
mixer.Sound.play(self, loops=loops)
|
|
|
|
|
|
|
|
def set_volume(self, volume, force=False):
|
|
|
|
# Note: force only intended to be used by fader
|
|
|
|
if not self.volume or force:
|
|
|
|
mixer.Sound.set_volume(self, volume)
|
|
|
|
|
|
|
|
class TTSSound(SoundEffect):
|
2017-06-11 19:22:03 -04:00
|
|
|
'''
|
|
|
|
Special case of a SoundEffect wherein the sound is a speech file dynamically
|
|
|
|
created by espeak and stored in tmp.
|
|
|
|
'''
|
2016-12-30 02:51:56 -05:00
|
|
|
def __init__(self, path):
|
|
|
|
super().__init__(path, volume=1.0, loops=0)
|
|
|
|
self.size = os.path.getsize(path)
|
|
|
|
|
|
|
|
def __del__(self):
|
|
|
|
if os.path.exists(self.path):
|
|
|
|
os.remove(self.path)
|
|
|
|
|
|
|
|
class TTSCache(OrderedDict):
|
2017-06-11 19:22:03 -04:00
|
|
|
'''
|
|
|
|
Manages a list of all TTSSounds stored in tmp, and remembers the order files
|
|
|
|
have been added. Amount of data shall not exceed memLimit; once memLimit is
|
|
|
|
exceeded, files will be removed in FIFO manner
|
|
|
|
'''
|
2016-12-30 02:51:56 -05:00
|
|
|
def __init__(self, memLimit):
|
|
|
|
super().__init__()
|
|
|
|
self._memLimit = memLimit
|
|
|
|
self._memUsed = 0
|
|
|
|
|
|
|
|
def __setitem__(self, key, value):
|
|
|
|
if type(value) != TTSSound:
|
|
|
|
raise TypeError
|
|
|
|
OrderedDict.__setitem__(self, key, value)
|
|
|
|
self._memUsed += value.size
|
|
|
|
self._maintainMemLimit()
|
|
|
|
|
|
|
|
def __delitem__(self, key):
|
|
|
|
self._memUsed -= self[key].size
|
|
|
|
OrderedDict.__delitem__(self, key)
|
|
|
|
|
|
|
|
def clear(self):
|
|
|
|
logger.debug('Clearing TTS Cache')
|
|
|
|
OrderedDict.clear(self)
|
|
|
|
self._memUsed = 0
|
|
|
|
|
|
|
|
def _maintainMemLimit(self):
|
|
|
|
while self._memUsed > self._memLimit:
|
|
|
|
OrderedDict.popitem(self, last=False)
|
|
|
|
|
|
|
|
class SoundLib:
|
2017-06-11 19:22:03 -04:00
|
|
|
'''
|
|
|
|
Main wrapper for pygame.mixer, including methods for changing overall
|
|
|
|
volume, handling TTS, and hlding the soundfx table for importation
|
|
|
|
elsewhere. Note that the TTS listener is started as a separate thread,
|
|
|
|
and speech bits are sent to be prcoess with a queue (which is to be passed
|
|
|
|
to other threads)
|
|
|
|
'''
|
2016-12-30 02:51:56 -05:00
|
|
|
|
|
|
|
_sentinel = None
|
|
|
|
|
2017-05-21 14:29:17 -04:00
|
|
|
def __init__(self):
|
2016-12-30 02:51:56 -05:00
|
|
|
mixer.pre_init(frequency=44100, size=-16, channels=2, buffer=1024)
|
|
|
|
mixer.init()
|
|
|
|
|
|
|
|
self.soundEffects = {
|
|
|
|
'disarmed': SoundEffect(path='soundfx/smb_pause.wav'),
|
2017-06-18 15:16:42 -04:00
|
|
|
'armedCountdown': SoundEffect(path='soundfx/smb_kick.wav'),
|
2016-12-30 02:51:56 -05:00
|
|
|
'armed': SoundEffect(path='soundfx/smb_powerup.wav'),
|
2017-06-18 15:00:01 -04:00
|
|
|
'lockedCountdown': SoundEffect(path='soundfx/smb_stomp.wav'),
|
2017-06-18 15:16:42 -04:00
|
|
|
'locked': SoundEffect(path='soundfx/smb_1-up.wav'),
|
|
|
|
'trippedCountdown': SoundEffect(path='soundfx/smb2_door_appears.wav'),
|
|
|
|
'tripped': SoundEffect(path='soundfx/alarms/burgler_alarm.ogg', volume=1.0, loops=-1),
|
2016-12-30 02:51:56 -05:00
|
|
|
'door': SoundEffect(path='soundfx/smb_pipe.wav'),
|
|
|
|
'numKey': SoundEffect(path='soundfx/smb_bump.wav'),
|
|
|
|
'ctrlKey': SoundEffect(path='soundfx/smb_fireball.wav'),
|
|
|
|
'wrongPass': SoundEffect(path='soundfx/smb_fireworks.wav'),
|
|
|
|
'backspace': SoundEffect(path='soundfx/smb_breakblock.wav'),
|
|
|
|
}
|
|
|
|
|
|
|
|
self._ttsSounds = TTSCache(psutil.virtual_memory().total * 0.001)
|
2017-06-11 20:05:33 -04:00
|
|
|
self._lock = RLock()
|
2016-12-30 02:51:56 -05:00
|
|
|
|
|
|
|
self.volume = 100
|
|
|
|
self._applyVolumesToSounds(self.volume)
|
|
|
|
|
2017-06-02 02:17:32 -04:00
|
|
|
self._ttsQueue = queue.Queue()
|
2017-06-03 22:36:20 -04:00
|
|
|
self._stopper = Event()
|
2017-06-07 01:42:58 -04:00
|
|
|
|
|
|
|
def start(self):
|
2016-12-30 02:51:56 -05:00
|
|
|
self._startMonitor()
|
2017-06-10 01:52:06 -04:00
|
|
|
|
|
|
|
def stop(self):
|
|
|
|
self._stopMonitor()
|
|
|
|
self._ttsSounds.clear()
|
|
|
|
# this sometimes casues "Fatal Python error: (pygame parachute) Segmentation Fault"
|
|
|
|
mixer.quit()
|
2016-12-30 02:51:56 -05:00
|
|
|
|
|
|
|
def changeVolume(self, volumeDelta):
|
|
|
|
newVolume = self.volume + volumeDelta
|
|
|
|
if newVolume >= 0 and newVolume <= 100:
|
|
|
|
self._applyVolumesToSounds(newVolume)
|
|
|
|
|
|
|
|
def mute(self):
|
|
|
|
self._applyVolumesToSounds(0)
|
|
|
|
|
|
|
|
def speak(self, text):
|
2017-05-21 14:29:17 -04:00
|
|
|
self._ttsQueue.put_nowait(text)
|
2017-06-07 01:42:58 -04:00
|
|
|
|
2016-12-30 02:51:56 -05:00
|
|
|
@async(daemon=False)
|
|
|
|
def _fader(self, lowerVolume, totalDuration, fadeDuration=0.2, stepSize=5):
|
2017-06-11 19:34:24 -04:00
|
|
|
with self._lock:
|
2017-06-18 15:16:42 -04:00
|
|
|
alarm = self.soundEffects['tripped']
|
2017-06-11 19:34:24 -04:00
|
|
|
alarmVolume = alarm.volume
|
|
|
|
alarmVolumeDelta = alarmVolume - lowerVolume
|
2016-12-30 02:51:56 -05:00
|
|
|
|
2017-06-11 19:34:24 -04:00
|
|
|
masterVolume = self.volume
|
|
|
|
masterVolumeDelta = self.volume - lowerVolume
|
2016-12-30 02:51:56 -05:00
|
|
|
|
2017-06-11 19:34:24 -04:00
|
|
|
sleepFadeTime = fadeDuration / stepSize
|
|
|
|
|
|
|
|
for i in range(0, stepSize):
|
|
|
|
if alarmVolumeDelta > 0:
|
|
|
|
alarm.set_volume(alarmVolume - alarmVolumeDelta * i / stepSize, force=True)
|
|
|
|
|
|
|
|
if masterVolumeDelta > 0:
|
|
|
|
self._applyVolumesToSounds(masterVolume - masterVolumeDelta * i / stepSize)
|
|
|
|
|
|
|
|
time.sleep(sleepFadeTime)
|
2016-12-30 02:51:56 -05:00
|
|
|
|
2017-06-11 19:34:24 -04:00
|
|
|
time.sleep(totalDuration - 2 * fadeDuration)
|
2016-12-30 02:51:56 -05:00
|
|
|
|
2017-06-11 19:34:24 -04:00
|
|
|
for i in range(stepSize - 1, -1, -1):
|
|
|
|
if alarmVolumeDelta > 0:
|
|
|
|
alarm.set_volume(alarmVolume - alarmVolumeDelta * i / stepSize, force=True)
|
|
|
|
|
|
|
|
if masterVolumeDelta > 0:
|
|
|
|
self._applyVolumesToSounds(masterVolume - masterVolumeDelta * i / stepSize)
|
|
|
|
|
|
|
|
time.sleep(sleepFadeTime)
|
2016-12-30 02:51:56 -05:00
|
|
|
|
|
|
|
# will not change sounds that have preset volume
|
|
|
|
def _applyVolumesToSounds(self, volume):
|
2017-06-11 19:34:24 -04:00
|
|
|
with self._lock:
|
|
|
|
self.volume = volume
|
|
|
|
v = volume/100
|
|
|
|
s = self.soundEffects
|
|
|
|
for name, sound in s.items():
|
|
|
|
sound.set_volume(v)
|
2016-12-30 02:51:56 -05:00
|
|
|
|
|
|
|
def _ttsMonitor(self):
|
|
|
|
q = self._ttsQueue
|
2017-06-03 22:36:20 -04:00
|
|
|
while not self._stopper.isSet():
|
2016-12-30 02:51:56 -05:00
|
|
|
try:
|
|
|
|
text = self._ttsQueue.get(True)
|
|
|
|
if text is self._sentinel:
|
|
|
|
break
|
2017-05-21 14:29:17 -04:00
|
|
|
self._playSpeech(text)
|
2017-06-03 01:55:06 -04:00
|
|
|
q.task_done()
|
2016-12-30 02:51:56 -05:00
|
|
|
except queue.Empty:
|
|
|
|
pass
|
|
|
|
# There might still be records in the queue.
|
|
|
|
while 1:
|
|
|
|
try:
|
|
|
|
text = self._ttsQueue.get(False)
|
|
|
|
if text is self._sentinel:
|
|
|
|
break
|
2017-05-21 14:29:17 -04:00
|
|
|
self._playSpeech(text)
|
2017-06-03 01:55:06 -04:00
|
|
|
q.task_done()
|
2016-12-30 02:51:56 -05:00
|
|
|
except queue.Empty:
|
|
|
|
break
|
2017-05-21 14:29:17 -04:00
|
|
|
|
|
|
|
def _playSpeech(self, text):
|
|
|
|
basename = hashlib.md5(text.encode()).hexdigest()
|
|
|
|
|
|
|
|
if basename in self._ttsSounds:
|
|
|
|
self._ttsSounds.move_to_end(basename)
|
|
|
|
else:
|
|
|
|
path = '/tmp/' + basename
|
|
|
|
call(['espeak', '-a180', '-g8', '-p75', '-w', path, text])
|
|
|
|
self._ttsSounds[basename] = TTSSound(path)
|
|
|
|
|
|
|
|
self._fader(
|
|
|
|
lowerVolume=0.1,
|
|
|
|
totalDuration=self._ttsSounds[basename].get_length()
|
|
|
|
)
|
|
|
|
self._ttsSounds[basename].play()
|
|
|
|
logger.debug('TTS engine received "%s"', text)
|
|
|
|
|
2016-12-30 02:51:56 -05:00
|
|
|
def _startMonitor(self):
|
2017-06-02 02:17:32 -04:00
|
|
|
self._thread = t = ExceptionThread(target=self._ttsMonitor, daemon=True)
|
2016-12-30 02:51:56 -05:00
|
|
|
t.start()
|
|
|
|
logger.debug('Starting TTS Queue Monitor')
|
|
|
|
|
|
|
|
def _stopMonitor(self):
|
2017-06-03 22:36:20 -04:00
|
|
|
self._stopper.set()
|
2016-12-30 02:51:56 -05:00
|
|
|
self._ttsQueue.put_nowait(self._sentinel)
|
2017-06-24 00:47:54 -04:00
|
|
|
try:
|
|
|
|
self._thread.join()
|
|
|
|
self._thread = None
|
|
|
|
except AttributeError:
|
|
|
|
pass
|
2016-12-30 02:51:56 -05:00
|
|
|
logger.debug('Stopping TTS Queue Monitor')
|
|
|
|
|
|
|
|
def __del__(self):
|
2017-06-10 01:52:06 -04:00
|
|
|
self.stop()
|