Skip to content

Commit

Permalink
Merge da19364 into 28c11a0
Browse files Browse the repository at this point in the history
  • Loading branch information
ejhumphrey committed Feb 29, 2016
2 parents 28c11a0 + da19364 commit 5b5bc30
Show file tree
Hide file tree
Showing 9 changed files with 340 additions and 39 deletions.
6 changes: 4 additions & 2 deletions .travis.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
sudo: false
sudo: true

addons:
apt:
packages:
- sox
- ffmpeg

cache:
directories:
Expand All @@ -20,12 +21,13 @@ python:
- "3.5"

install:
- pip install pytest pytest-cov
- pip install coveralls
- pip install -e ./

script:
- python --version
- nosetests --with-coverage --cover-erase --cover-package=claudio -v -w ./
- py.test -vs --cov=claudio .

after_success:
- coveralls
Expand Down
173 changes: 173 additions & 0 deletions claudio/ffmpeg.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
"""Utilities to call ffmpeg from Python.
For more information, refer to https://www.ffmpeg.org/
"""
from __future__ import print_function

import logging
import os
import subprocess

import claudio.formats as formats
import claudio.util as util

try:
FileNotFoundError
except NameError:
FileNotFoundError = OSError

logging.basicConfig(level=logging.DEBUG)

# Error Message for SoX
__NO_FFMPEG__ = """ffmpeg could not be found!
If you do not have ffmpeg, proceed here:
- - - https://www.ffmpeg.org/ - - -
If you do (or think that you should) have ffmpeg, double-check your
path variables."""


class FFMpegError(BaseException):
pass


def __BIN__():
return 'ffmpeg'


def _check():
"""Test for FFMpeg.
Returns
-------
success : bool
True if it looks like ffmpeg exists.
"""
try:
info = subprocess.check_output([__BIN__(), '-version'])
status = 'ffmpeg version' in str(info)
message = __NO_FFMPEG__
except (FileNotFoundError, subprocess.CalledProcessError) as derp:
status = False
message = __NO_FFMPEG__ + "\n{}".format(derp)
if status:
logging.info(message)
else:
logging.warning(message)
return status


def check_ffmpeg():
"""Assert that SoX is present and can be called."""
if not _check():
raise FFMpegError("SoX check failed.\n{}".format(__NO_FFMPEG__))


def convert(input_file, output_file,
samplerate=None, channels=None, bytedepth=None):
"""Converts one audio file to another on disk.
Parameters
----------
input_file : str
Input file to convert.
output_file : str
Output file to writer.
samplerate : float, default=None
Desired samplerate. If None, defaults to the same as input.
channels : int, default=None
Desired channels. If None, defaults to the same as input.
bytedepth : int, default=None
Desired bytedepth. If None, defaults to the same as input.
Returns
-------
status : bool
True on success.
"""
args = ['-i', input_file]

if bytedepth:
raise NotImplementedError("Haven't gotten here yet.")
if channels:
args += ['-ac', str(channels)]
if samplerate:
args += ['-ar', str(samplerate)]

args += [output_file, '-y']

return ffmpeg(args)


def ffmpeg(args):
"""Pass an argument list to ffmpeg.
Parameters
----------
args : list
Argument list for ffmpeg.
Returns
-------
success : bool
True on successful calls to ffmpeg.
"""
check_ffmpeg()
util.validate_clargs(args)
args = [__BIN__()] + list(args)

try:
logging.debug("Executing: {}".format(args))
process_handle = subprocess.Popen(args, stderr=subprocess.PIPE)
status = process_handle.wait()
logging.debug(process_handle.stdout)
return status == 0
except OSError as error_msg:
logging.error("OSError: ffmpeg failed! {}".format(error_msg))
except TypeError as error_msg:
logging.error("TypeError: {}".format(error_msg))
return False


def is_valid_file_format(input_file):
"""Determine if a given file is supported by FFMPEG based on its
extension.
Parameters
----------
input_file : str
Audio file to verify for support.
Returns
-------
status : bool
True if supported.
"""
raise NotImplementedError("Come back to this.")
# File extension
file_ext = os.path.splitext(input_file)[-1]
# Return False if the file lacks an extension.
if not file_ext:
return False
# Remove dot-separator.
file_ext = file_ext.strip(".")
# Pure wave support
if file_ext == formats.WAVE:
return True

# Otherwise, check against SoX.
else:
check_ffmpeg()
info = str(subprocess.check_output([__BIN__(), '-codecs']))
valid = file_ext in info
if valid:
logging.debug("FFMpeg supports '{}' files.".format(file_ext))
else:
logging.debug("SoX does not support '{}' files.".format(file_ext))

return valid
82 changes: 58 additions & 24 deletions claudio/fileio.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,29 @@
import os
import wave

import claudio.ffmpeg as ffmpeg
import claudio.formats as formats
import claudio.sox as sox
import claudio.util as util

SOX = 'sox'
FFMPEG = 'ffmpeg'

CONVERTERS = {
SOX: sox,
FFMPEG: ffmpeg
}


class AudioError(BaseException):
pass


class AudioFile(object):
"""Abstract AudioFile base class."""

def __init__(self, filepath, samplerate=None, channels=None,
bytedepth=None, mode="r"):
bytedepth=None, mode="r", converter=SOX):
"""Base class for interfacing with audio files.
When writing audio files, samplerate, channels, and bytedepth must be
Expand All @@ -38,11 +51,14 @@ def __init__(self, filepath, samplerate=None, channels=None,
mode : str, default='r'
Open the file for [r]eading or [w]riting.
converter : str
Conversion tool to use, currently one of ['sox', 'ffmpeg'].
"""
logging.debug(util.classy_print(AudioFile, "Constructor."))
if not sox.is_valid_file_format(filepath):
raise ValueError("Cannot handle this filetype: {}"
"".format(filepath))
# if not CONVERTERS[converter].is_valid_file_format(filepath):
# raise ValueError("Cannot handle this filetype: {}"
# "".format(filepath))
if mode == "w":
# TODO: If/raise
assert samplerate, "Writing audiofiles requires a samplerate."
Expand All @@ -52,6 +68,7 @@ def __init__(self, filepath, samplerate=None, channels=None,
self._filepath = filepath
self._wave_handle = None
self._temp_filepath = util.temp_file(formats.WAVE)
self._converter = converter

self._mode = mode
logging.debug(util.classy_print(AudioFile, "Opening wave file."))
Expand Down Expand Up @@ -89,13 +106,15 @@ def __get_handle__(self, filepath, samplerate, channels, bytedepth):
self._CONVERT = True

if self._CONVERT:
# TODO: Catch status, raise on != 0
assert sox.convert(input_file=filepath,
output_file=self._temp_filepath,
samplerate=samplerate,
bytedepth=bytedepth,
channels=channels), \
"SoX Conversion failed for '%s'." % filepath
success = CONVERTERS[self._converter].convert(
input_file=filepath,
output_file=self._temp_filepath,
samplerate=samplerate,
bytedepth=bytedepth,
channels=channels)
if not success:
raise AudioError(
"Conversion failed for '{}'.".format(filepath))
self._wave_handle = wave.open(self._temp_filepath, 'r')
else:
fmt_ext = os.path.splitext(self.filepath)[-1].strip('.')
Expand Down Expand Up @@ -133,12 +152,15 @@ def close(self):
logging.debug(
util.classy_print(AudioFile,
"Conversion required for writing."))
# TODO: Update to if / raise
assert sox.convert(input_file=self._temp_filepath,
output_file=self.filepath,
samplerate=self.samplerate,
bytedepth=self.bytedepth,
channels=self.channels)
success = CONVERTERS[self._converter].convert(
input_file=self._temp_filepath,
output_file=self.filepath,
samplerate=self.samplerate,
bytedepth=self.bytedepth,
channels=self.channels)
if not success:
raise AudioError(
"Conversion failed for '{}'.".format(self.filepath))
if self._temp_filepath and os.path.exists(self._temp_filepath):
logging.debug(util.classy_print(AudioFile,
"Temporary file deleted."))
Expand Down Expand Up @@ -223,7 +245,7 @@ class FramedAudioFile(AudioFile):
def __init__(self, filepath, framesize,
samplerate=None, channels=None, bytedepth=None, mode='r',
time_points=None, framerate=None, stride=None, overlap=0.5,
alignment='center', offset=0):
alignment='center', offset=0, converter=SOX):
"""Frame-based audio file parsing.
Parameters
Expand Down Expand Up @@ -264,6 +286,9 @@ def __init__(self, filepath, framesize,
offset : scalar, default = 0
Time in seconds to shift the alignment of a frame.
converter : str
Conversion tool to use, currently one of ['sox', 'ffmpeg'].
Notes
-----
For frame-based audio processing, there are a few roughly equivalent
Expand Down Expand Up @@ -291,7 +316,7 @@ def __init__(self, filepath, framesize,
logging.debug(util.classy_print(FramedAudioFile, "Constructor."))
super(FramedAudioFile, self).__init__(
filepath, samplerate=samplerate, channels=channels,
bytedepth=bytedepth, mode=mode)
bytedepth=bytedepth, mode=mode, converter=converter)

self._framesize = framesize
self._alignment = alignment
Expand Down Expand Up @@ -535,15 +560,16 @@ class FramedAudioReader(FramedAudioFile):
def __init__(self, filepath, framesize,
samplerate=None, channels=None, bytedepth=None,
overlap=0.5, stride=None, framerate=None, time_points=None,
alignment='center', offset=0):
alignment='center', offset=0, converter=SOX):

# Always read.
mode = 'r'
logging.debug(util.classy_print(FramedAudioReader, "Constructor."))
self._wave_handle = None
super(FramedAudioReader, self).__init__(
filepath, framesize, samplerate, channels, bytedepth, mode,
time_points, framerate, stride, overlap, alignment, offset)
time_points, framerate, stride, overlap, alignment, offset,
converter)

def read_frame_at_index(self, sample_index, framesize=None):
"""Read 'framesize' samples starting at 'sample_index'.
Expand Down Expand Up @@ -616,7 +642,8 @@ def __next__(self):
return self.next()


def read(filepath, samplerate=None, channels=None, bytedepth=None):
def read(filepath, samplerate=None, channels=None, bytedepth=None,
converter=SOX):
"""Read the entirety of a sound file into memory.
Parameters
Expand All @@ -630,6 +657,9 @@ def read(filepath, samplerate=None, channels=None, bytedepth=None):
channels: int, or None for file's default
Number of channels for the returned audio signal.
converter : str
Conversion tool to use, currently one of ['sox', 'ffmpeg'].
Returns
-------
signal: np.ndarray
Expand All @@ -641,7 +671,8 @@ def read(filepath, samplerate=None, channels=None, bytedepth=None):
def_framesize = 50000
reader = FramedAudioReader(
filepath, framesize=def_framesize, samplerate=samplerate,
channels=channels, bytedepth=bytedepth, overlap=0, alignment='left')
channels=channels, bytedepth=bytedepth, overlap=0, alignment='left',
converter=converter)
signal = np.zeros([reader.num_frames * reader.framesize,
reader.channels])
# Step through the file
Expand All @@ -651,7 +682,7 @@ def read(filepath, samplerate=None, channels=None, bytedepth=None):
return signal[:reader.num_samples], reader.samplerate


def write(filepath, signal, samplerate=44100, bytedepth=2):
def write(filepath, signal, samplerate=44100, bytedepth=2, converter=SOX):
"""Write an audio signal to disk.
Parameters
Expand All @@ -667,6 +698,9 @@ def write(filepath, signal, samplerate=44100, bytedepth=2):
bytedepth : int, default=2
Number of bytes for the audio signal; must be 2.
converter : str
Conversion tool to use, currently one of ['sox', 'ffmpeg'].
"""
if bytedepth != 2:
raise NotImplementedError("Currently only 16-bit audio is supported.")
Expand Down
Loading

0 comments on commit 5b5bc30

Please sign in to comment.