"""
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()