#! /usr/bin/python

# Author: Ryan Dellana
# Date Created: Feb. 16, 2014
# Updated: December 20, 2016
# Note: This version was created for Daniel Tobias

"""
Dependencies:
sudo apt-get install python-pygame
sudo apt-get install festlex-cmu
sudo apt-get install ubuntu-restricted-extras
sudo dpkg-reconfigure libdvd-pkg
# restart computer

mirage_sound_out provides a simple way of playing sounds that come from outside sources (mp3, 3rd party text-to-speech, etc.).
mirage_sound_out was created as an alternative to the soundplay system that comes with ros hydro.
Unlike soundplay, this node is not suitable for use by multiple clients (they'll step on each other).

If it recieves a new sound while one is still playing, it'll just play them concurrently on separate audio channels.
Unless all 8 channels are already in use in which case it'll just drop the sound.
There is no queue.
In the future I'd like to have timing info passed along with the sounds in a simlar way as motor commands are sent.
So you'd pass it a schedule of sounds to play, consisting of a list of tuples each containing a sound and it's start time.
When processing such a list, this node could look ahead to the synthesized text portions and start generating their files
before the scheduled play time to reduce lag.

        Useful pygame.mixer.Sound properties.
        .play               -- begin sound playback
        .stop               -- stop sound playback
        .fadeout            -- stop sound playback after fading out
        .set_volume         -- set the playback volume for this Sound
        .get_volume         -- get the playback volume
        .get_num_channels   -- count how many times this Sound is playing
        .get_length         -- get the length of the Sound
        .get_raw            -- return a bytestring copy of the Sound samples
"""

import sys, os, time, json, shutil, copy
import numpy
import pygame.mixer
from pyfestival import Festival
import rospy
from std_msgs.msg import String

class mirage_sound_out(object):

    """ festival_cache_path: folder where pre-synthesized mp3 files will be stored.
        sound_effects_path: folder where you keep your sound effect files.
    """
    def __init__(self, festival_cache_path, sound_effects_path):
        pygame.mixer.init(channels=6)   
        self._festival_cache_path = festival_cache_path
        self._festival_tmp_cache_path = "/tmp/mirage_festival_tmp/"
        if not os.path.exists(self._festival_tmp_cache_path):
            os.makedirs(self._festival_tmp_cache_path)
        self._sound_effects_path = sound_effects_path
        self._sounds_preloaded = {} # TODO Not currently used.
        self._fest = Festival()
        self._load_cache()
        self._timestamp = time.time()
        self._get_runtime_ts()
        self._enforce_consistency()
        self._prune_cache()
        self._save_cache()
        print "finished running self.__init__"

    def shutdown(self):
        print 'shutdown routine...'
        self._save_cache()
        #pygame.mixer.stop()
        shutil.rmtree(self._festival_tmp_cache_path)
        pygame.mixer.quit()

    """ Automatically creates a cache file if there isn't already one. """
    def _load_cache(self):
        c_path = self._festival_cache_path + "festival_cache_index.json"
        if not os.path.exists(c_path): # create_cache
            self._festival_cache_index = { 'cache':{}, 'newest_cache_id':0, 'cache_max_size':100*1024, 'cache_size':0,
                                           'save_cache_after_n_updates':50, 'cache_updates_since
_last_save':0, 
                                           'runtime_clock':0, 'synth_max_chars':150, 'prune_after':24*60*60 }
        else:
            in_file = open(self._festival_cache_path + "festival_cache_index.json") # TODO error handling?
            self._festival_cache_index = json.load(in_file)
        

    """ Make sure that all files specified in index are present in 
cache and vice versa. """
    # TODO Need to test this feature. Try removing some of the files from the cache and see if it adapts.
    #   Also try manually corrupting the index by adding references to files that aren't there.
    def _enforce_consistency(self):
        print "_enforce_consistency()"
        cache_files = os.listdir(self._festival_cache_path)
        ammended_cache = copy.deepcopy(self._festival_cache_index)
        ammended_cache['cache'] = {}
        for k in self._festival_cache_index['cache'].keys():
            ammended_cache['cache'][k] = copy.deepcopy(self._festival_cache_index['cache'][k])
            if ammended_cache['cache'][k]['fname'] != "" and ammended_cache['cache'][k]['fname'] not in cache_files:
                print k, "not found in cache. Removing from index."
                ammended_cache['cache'][k]['fname'] = ""
                ammended_cache['cache_size'] -= ammended_cache['cache'][k]['size']
                ammended_cache['cache'][k]['size'] = 0
        self._festival_cache_index = ammended_cache
        index_files = [self._festival_cache_index['cache'][k]['fname'] for k in self._festival_cache_index['cache'].keys()]
        for f in cache_files:
            if f not in index_files and f.count("festival_cache_index") == 0:
                print f, "not found in index. Removing from cache."
                try:
                    os.remove(self._festival_cache_path + f)
                except:
                    print "ERROR: os.remove(" + self._festival_cache_path + f + ")"
                    pass

    def _prune_cache(self):
        for k in self._festival_cache_index['cache'].keys():
            item = self._festival_cache_index['cache'][k]
            if item['size'] == 0 and (self._get_runtime_ts() - item['runtime_stamp'] > self._festival_cache_index['prune_after']):
                del self._festival_cache_index['cache'][k]
                    
    def _save_cache(self):
        print "_save_cache():" #, self._festival_cache_index
        output_file = open(self._festival_cache_path + "festival_cache_index.json", "wb")
        if output_file is not None:
            json.dump(self._festival_cache_index, output_file, indent=4)
        else:
            print "ERROR: output_file is None"
        output_file.close()

    def say(self, txt):
        print "say(", txt, ")"
        if len(txt) <= self._festival_cache_index['synth_max_chars']:
            cln_txt = self._clean_phrase(txt)
            if cln_txt not in self._festival_cache_index['cache'].keys(): # First time ever said.
                self._festival_cache_index['cache'][cln_txt] = { 'fname':"", 'size':0.0, 'count':0, 'runtime_stamp':0, 'delete_score':0 }
            elif self._festival_cache_index['cache'][cln_txt]['fname'] == "" and self._festival_cache_index['cache'][cln_txt]['count'] >= 1: # Second time ever been said.
                self._cache_phrase(txt)
            return self._say(txt)
        return 0.0

    """ updates cache index runtime_stamp and than returns it's new value """
    def _get_runtime_ts(self):
        current_time = time.time()
        time_elapsed = current_time - self._timestamp
        self._festival_cache_index['runtime_clock'] = self._festival_cache_index['runtime_clock'] + time_elapsed
        self._timestamp = current_time
        print "runtime_clock:", str(self._festival_cache_index['runtime_clock'])
        return self._festival_cache_index['runtime_clock']

    """ Remove all chars other than number, letter, and space """
    def _clean_phrase(self, txt):
        phrase = txt.lower()
        out = ""
        for ch in phrase:
            if (ord(ch) >= ord('a') and ord(ch) <= ord('z')) or (ord(ch) == ord(' ')) or (ord(ch) >= ord('0') and ord(ch) <= ord('9')):
                out += ch
        return out

    def _cache_phrase(self, txt):
        print "_cache_phrase(", txt, ")"
        cache_id = self._festival_cache_index['newest_cache_id'] + 1
        cache_id_str = "cid" + str(cache_id) + ".wav"
        _dest = self._festival_cache_path + cache_id_str
        try:
            self._fest.wave(text=txt, dest=_dest)
        except:
            print "ERROR: text2wave. txt=", txt, "dest=", _dest
