"""
Python module to read, play, and write sound data.
For flexibility, FFMPEG is used for non-WAV files.
If you re-set the location of FFMPEG, please run the following code:
>>> ffmpeg = FFMPEG_info()
>>> ffmpeg.set()
You can obtain it for free from
    http://ffmpeg.org
Mac users using Anaconda should follow the instructions on
        https://anaconda.org/soft-matter/ffmpeg
    Otherwise, the tips under
        https://github.com/fluent-ffmpeg/node-fluent-ffmpeg/wiki/Installing-ffmpeg-on-Mac-OS-X
    seemed to work. Binaries are also available from
        - http://www.evermeet.cx/ffmpeg/ffmpeg-4.1.1.7z
        - http://www.evermeet.cx/ffplay/ffplay-4.1.1.7z
Note that FFMPEG must be installed externally, not as a Python package!!
Please install ffmpeg/ffplay in the following directory:
    - Windows:  "C:\\\\Program Files\\\\ffmpeg\\\\bin\\\\"
    - Mac:  	"/usr/local/bin/" (is already included in the default paths of the Mac terminal.)
    - Linux:	"/usr/bin/"
Compatible with Python >=3.5
"""
# Author: thomas haslwanter
# Date:   Oct-2024
# "ffmpeg" has to be installed externally, into the location listed below
# You can obtain it for free from http://ffmpeg.org
import numpy as np
from scipy.io import wavfile
import tempfile
import subprocess
import json
import time
from pathlib import Path
import appdirs
import yaml
# The following construct is required since I want to run the module as a script
# inside the sksound-directory
import os
import sys
file_dir = os.path.dirname(__file__)
if file_dir not in sys.path:
    sys.path.insert(0, file_dir)
import misc
# from sksound import misc
# On Win playing sound works automatically
# For the other packages you need the module "pygame"
if sys.platform=='win32':
    import winsound
elif sys.platform == 'linux':
    import pygame
[docs]
class NoFFMPEG_Error(Exception):
    pass 
[docs]
class FFMPEG_info:
    """
    Class for storing the config-info for FFMPEG.
    If first checks if FFMPEG is installed. FFMPEG is necessary to
    read MP3-files etc. Once checked, the corresponding information
    is saved in "ffmpeg.json", under "/FFMPEG_info/sksound/":
    FFMPEG_info properties:
        - config_file : JSON-file, with the config-information
        - ffmpeg : Commandline location of the command "ffmpeg"
        - ffplay : Commandline location of the command "ffplay"
    """
    def __init__(self):
        """Set the name of the config-file, and the properties
        "ffmpeg" and "ffplay" of the FFMPEG_info object"""
        app_name = 'FFMPEG_info'
        app_author = 'sksound'
        # The package "appdirs" allows an OS-independent implementation
        user_data_dir = appdirs.user_data_dir(app_name, app_author)
        if not os.path.exists(user_data_dir):
            os.makedirs(user_data_dir)
        self.config_file =  os.path.join(user_data_dir, 'ffmpeg.json')
        if not os.path.exists(self.config_file):
            # Check if it is in the system path
            try:
                completed_process = subprocess.run(
                    'ffmpeg', stderr=subprocess.DEVNULL)
                completed_process = subprocess.run('ffplay', stderr=subprocess.DEVNULL)
                self.ffmpeg = 'ffmpeg'
                self.ffplay = 'ffplay'
            except FileNotFoundError:
                self.set()
        else:
            with open(self.config_file, 'r') as in_file:
                info = json.load(in_file)
                self.ffmpeg = info['ffmpeg']
                self.ffplay = info['ffplay']
[docs]
    def set(self):
        """
        Set the config-filename, and write the FFMPEG_info
        properties "ffmpeg" and "ffplay" to that config-file.
        If FFMPEG is not installed, these are set to "None".
        """
        ffmpeg_installed = misc.askquestion(DialogTitle='FFMPEG Check',
                                           Question='Is FFMPEG installed?')
        if ffmpeg_installed:
            ffmpeg_dir = misc.get_dir(DialogTitle='Please select the directory where FFMPEG (binary) is installed:')
            if sys.platform=='win32':
                self.ffmpeg = ffmpeg_dir/'ffmpeg.exe'
                self.ffplay = ffmpeg_dir/'ffplay.exe'
            else:
                self.ffmpeg = ffmpeg_dir/'ffmpeg'
                self.ffplay = ffmpeg_dir/'ffplay'
            if not os.path.exists(self.ffmpeg):
                print('Sorry, {0} does not exist!'.format(self.ffmpeg))
                return
            if not os.path.exists(self.ffplay):
                print('Sorry, {0} does not exist!'.format(self.ffplay))
                return
        else:
            self.ffmpeg = None
            self.ffplay = None
        # Save them to the default config file
        info = {'ffmpeg':self.ffmpeg, 'ffplay': self.ffplay}
        try:
            with open(self.config_file, 'w') as outFile:
                json.dump(info, outFile)
                print('Config information written to {0}'.format(os.path.abspath(self.config_file)))
        except PermissionError as e:
            curDir = os.path.abspath(os.curdir)
            print('Current directory: {0}'.format(curDir))
            print('Error: {0}'.format(e))
        return 
 
[docs]
class Sound:
    """
    Class for working with sound in Python.
    A Sound object can be initialized
        - by giving a filename
        - by providing "int16" data and a rate
        - without giving any parameter; in that case the user is prompted
          to select an infile
    Parameters
    ----------
    inFile : string or pathlib-path
        path- and file-name of infile, if you get the sound from a file.
    inData: array
        manually generated sound data; requires "inRate" to be set, too.
    inRate: integer
        sample rate; required if "inData" are entered.
    Returns
    -------
    None :
        No return value. Initializes the Sound-properties.
    Notes
    -----
    For non WAV-files, the file is first converted to WAV using
    FFMPEG, and then read in. A warning is generated, to avoid
    unintentional deletion of existing WAV-files.
    SoundProperties:
        - source
        - data
        - rate
        - numChannels
        - totalSamples
        - duration
        - bitsPerSample
    SoundMethods:
        - generate_sound
        - get_info
        - play
        - read_sound
        - summary
        - write_wav
    Examples
    --------
    >>> from sksound.sounds import Sound
    >>> mySound1 = Sound()                  # here the user is prompted for an input file
    >>> mySound2 = Sound('test.wav')     # here the input file is provided directly
    >>>
    >>> rate = 22050
    >>> dt = 1./rate
    >>> freq = 440
    >>> t = np.arange(0,0.5,dt)
    >>> x = np.sin(2*np.pi*freq * t)
    >>> amp = 2**13
    >>> sounddata = np.int16(x*amp)
    >>> mySound3 = Sound(inData=sounddata, inRate=rate)
    """
    def __init__(self, inFile: str|os.PathLike = '', inData: np.ndarray|None = None, inRate:
                 float|None = None):
        """ Initialize a Sound object """
        # Information about FFMPEG
        self.ffmpeg_info = FFMPEG_info()
        #self.data = np.empty(0)
        if inData is not None:
            if inRate is None:
                print('Set the "rate" to the default value (8012 Hz).')
                rate = 8012.0
            self.generate_sound(inData, inRate)
        else:
            if inFile == '':
                inFile = self._selectInput()
                if inFile == 0:
                    return
            try:
                self.source = str(inFile)
                self.read_sound(self.source)
            except FileNotFoundError as err:
                print(err)
                inFile = self._selectInput()
                self.source = inFile
                self.read_sound(self.source)
[docs]
    def read_sound(self, inFile):
        """
        Read data from a sound-file.
        Parameters
        ----------
        inFile : string
            path- and file-name of infile
        Returns
        -------
        None :
            No return value. Sets the property "data" of the object.
        Notes
        -----
        * For non WAV-files, the file is first converted to WAV using
          FFMPEG, and then read in.
        * If FFMPEG is not installed, non-WAV files produce a "sounds.NoFFMPEG_Error"
        Examples
        --------
        >>> mySound = Sound('test.wav')
        >>> mySound.play()
        >>> mySound.read_sound('test2.wav') # If you want to read in another(!) file
        """
        # Python can natively only read "wav" files. To be flexible, use "ffmpeg" for conversion for other formats
        if not os.path.exists(inFile):
            print('{0} does not exist!'.format(inFile))
            raise FileNotFoundError
        (root, ext) = os.path.splitext(inFile)
        if ext[1:].lower() != 'wav':
            if self.ffmpeg_info.ffmpeg == None:
                print('Sorry, need FFMPEG for non-WAV files!')
                self.rate = None
                self.data = None
                raise NoFFMPEG_Error
            outFile = root + '.wav'
            cmd = [self.ffmpeg_info.ffmpeg, '-i', inFile, outFile, '-y']
            subprocess.run(cmd)
            print('Infile converted from ' + ext + ' to ".wav"')
            inFile = outFile
            self.source = outFile
        self.rate, self.data = wavfile.read(inFile)
        # Set the filename
        self.source = inFile
        # Make sure that the data are in some integer format
        # Otherwise, e.g. Windows has difficulty playing the sound
        # Note that "self.source" is set to "None", in order to
        # play the correct, converted file with "play"
        if not np.issubdtype(self.data.dtype, np.integer):
            self.generate_sound(self.data, self.rate)
        self._setInfo()
        print('data read in!') 
[docs]
    def play(self):
        """
       Play the stored sound
       Parameters
       ----------
       None :
       Returns
       -------
       None :
       Notes
       -----
       On "Windows" the module "winsound" is used; on "Linux" I use
       "pygame"; and on "OSX" the terminal command "afplay".
       Examples
       --------
       >>> mySound = Sound('test.wav')
       >>> mySound.play()
        """
        try:
            if self.source is None:
                # If there is no source-file, write the data to a temporary WAV-file ...
                tmpFile = tempfile.NamedTemporaryFile(suffix='.wav', delete=False)
                tmpFile.close()
                self.write_wav(Path(tmpFile.name))
                # ... and play that file
                if sys.platform=='win32':
                    winsound.PlaySound(tmpFile.name, winsound.SND_FILENAME)
                elif sys.platform == 'darwin':
                    cmd = ['afplay', tmpFile.name]
                    subprocess.run(cmd)
                else:
                    pygame.init()
                    pygame.mixer.music.load(tmpFile.name)
                    pygame.mixer.music.play()
                    time.sleep(self.duration)
                    # If you want to use FFMPEG instead, use the following commands:
                    #cmd = [self.ffmpeg_info.ffplay, '-autoexit', '-nodisp', '-i', tmpFile.name]
                    #subprocess.run(cmd)
            elif os.path.exists(self.source):
                # If you have a given input file ...
                print('Playing ' + str(self.source))
                # ... then play that one
                if sys.platform == 'win32':
                    winsound.PlaySound(str(self.source), winsound.SND_FILENAME)
                elif sys.platform == 'darwin':
                    cmd = ['afplay', str(self.source)]
                    subprocess.run(cmd)
                else:
                    pygame.init()
                    pygame.mixer.music.load(self.source)
                    pygame.mixer.music.play()
                    time.sleep(self.duration)
                    # If you want to use FFMPEG instead, use the following commands:
                    #cmd = [self.ffmpeg_info.ffplay, '-autoexit', '-nodisp', '-i', self.source]
                    #subprocess.run(cmd)
        except SystemError:
            print('If you don''t have FFMPEG available, you can e.g. use installed audio-files. E.g.:')
            print('import subprocess')
            print('subprocess.run(["C:/Program Files (x86)/VideoLAN/VLC/vlc.exe", "C:/Music/14_Streets_of_Philadelphia.mp3"])') 
[docs]
    def generate_sound(self, data, rate):
        """ Set the properties of a Sound-object. """
        # If the data are not in an integer format (if they are e.g. "float"), convert
        # them to integer and scale them to a reasonable amplitude
        if not np.issubdtype(data.dtype, np.integer):
            defaultAmp = 2**13
            # Watch out with integer artefacts!
            data = np.int16(data * (defaultAmp / np.max(data)))
        self.data = data
        self.rate = rate
        self.source = None
        self._setInfo() 
[docs]
    def write_wav(self, out_file:os.PathLike|None = None) -> os.PathLike|None:
        """
        Write sound data to a WAV-file.
        Parameters
        ----------
        out_file : path of the outfile. If none is given,
            the user is asked interactively to choose a folder/name
            for the outfile.
        Returns
        -------
        out_file : path of the (selected) outfile
        Examples
        --------
        >>> mySound = Sound('test.wav')
        >>> mySound.write_wav()
        """
        if out_file is None:
            out_file = misc.save_file(filter_spec='*.wav', dialog_title='Write sound to ...',
                          default_name='')
            if out_file is None:
                print('Output discarded.')
                return None
        wavfile.write(str(out_file.absolute()), int(self.rate), self.data)
        print(f'Sounddata written to {out_file.name}, with a sample rate of {str(self.rate)}')
        print(f'OutDir: {out_file.parent}')
        return out_file 
[docs]
    def get_info(self):
        """
        Return information about the sound.
        Parameters
        ----------
        None :
        Returns
        -------
        source : name of inFile
        rate :   sampleRate
        numChannels : number of channels
        totalSamples : number of total samples
        duration : duration [sec]
        bitsPerSample : bits per sample
        Examples
        --------
        >>> mySound = Sound('test.wav')
        >>> info = mySound.get_info()
        >>> (source, rate, numChannels, totalSamples, duration, bitsPerSample) = mySound.get_info()
        """
        return (self.source,
                self.rate,
                self.numChannels,
                self.totalSamples,
                self.duration,
                self.dataType) 
[docs]
    def summary(self):
        """
        Display information about the sound.
        Parameters
        ----------
        None :
        Returns
        -------
        None :
        Examples
        --------
        >>> mySound = Sound()
        >>> mySound.read_sound('test.wav')
        >>> mySound.summary()
        """
        (source, rate, numChannels, totalSamples, duration, dataType) = self.get_info()
        info = {'Source':source,
                'SampleRate':rate,
                'NumChannels':numChannels,
                'TotalSamples':totalSamples,
                'Duration':duration,
                'DataType':dataType}
        print(yaml.dump(info, default_flow_style=False)) 
    def _setInfo(self):
        """ Set the information properties of that sound """
        if len(self.data.shape)==1:
            self.numChannels = 1
            self.totalSamples = len(self.data)
        else:
            self.numChannels = self.data.shape[1]
            self.totalSamples = self.data.shape[0]
        self.duration = float(self.totalSamples)/self.rate # [sec]
        self.dataType = str(self.data.dtype)
    def _selectInput(self) -> str | os.PathLike:
        """ GUI for the selection of an in-file. """
        my_file = misc.get_file(filter_spec='*.wav',
                                    dialog_title='Select sound-input:',
                                    default_name='')
        if my_file is None:
            print('No file selected')
            return ''
        else:
            # misc.get_file already shows the selection
            #print('Selection: ' + full_in_file)
            return my_file 
[docs]
def main():
    """ Main function, to test the module """
    import os
    import numpy as np
    # Re-set FFMPEG
    # ffmpeg = FFMPEG_info()
    # ffmpeg.set()
    # Import a file, and play the sound
    # data_dir = r'/home/thomas/Coding/scikit-sound/sksound/tests'
    data_dir = 'tests'
    in_file = 'a1.wav'
    full_file = os.path.join(data_dir, in_file)
    try:
        # mySound = Sound(full_file)
        # mySound.play()
        # time.sleep(mySound.duration)
        mySound2 = Sound()
        mySound2.play()
    except NoFFMPEG_Error:
        pass
    # Test with self-generated data
    rate = 22050
    dt = 1./rate
    t = np.arange(0,0.5,dt)
    freq = 880
    x = np.sin(2*np.pi*freq*t)
    sounddata = np.int16(x*2**13)
    in_sound = Sound(inData=sounddata, inRate=rate)
    in_sound.summary()
    in_sound.play()
    time.sleep(in_sound.duration)
    print('hi')
    # Test if type conversion works
    in_sound2 = Sound(inData=x, inRate=rate)
    in_sound2.play()
    # Test with GUI
    in_sound = Sound()
    in_sound.play()
    print(in_sound.summary())
    out = in_sound.get_info()
    print(out)
    in_sound.write_wav() 
if __name__ == '__main__':
    # my_sound = Sound()
    # my_sound.play()
    main()