diff --git a/audioread/__init__.py b/audioread/__init__.py index eecce32..5ecf0d4 100644 --- a/audioread/__init__.py +++ b/audioread/__init__.py @@ -112,3 +112,18 @@ def audio_open(path): # All backends failed! raise NoBackendError() + + +def decode(audio): + """Given a file-like object containing encoded audio data, create an + audio file object that produces its *raw* data. + """ + # FFmpeg. + from . import ffdec + try: + return ffdec.FFmpegAudioFile(audio=audio) + except DecodeError: + pass + + # All backends failed! + raise NoBackendError() diff --git a/audioread/ffdec.py b/audioread/ffdec.py index 622f31d..ca5b846 100644 --- a/audioread/ffdec.py +++ b/audioread/ffdec.py @@ -53,12 +53,12 @@ class ReadTimeoutError(FFmpegError): class QueueReaderThread(threading.Thread): - """A thread that consumes data from a filehandle and sends the data - over a Queue. + """A thread that consumes data from a file-like object and sends the + data over a Queue. """ - def __init__(self, fh, blocksize=1024, discard=False): + def __init__(self, file, blocksize=1024, discard=False): super(QueueReaderThread, self).__init__() - self.fh = fh + self.file = file self.blocksize = blocksize self.daemon = True self.discard = discard @@ -66,7 +66,7 @@ def __init__(self, fh, blocksize=1024, discard=False): def run(self): while True: - data = self.fh.read(self.blocksize) + data = self.file.read(self.blocksize) if not self.discard: self.queue.put(data) if not data: @@ -74,6 +74,31 @@ def run(self): break +class WriterThread(threading.Thread): + """A thread that reads data from one file-like object and writes it + to another, one block at a time. + """ + def __init__(self, writefile, readfile=None, blocksize=1024): + """Create a thread that reads data from `readfile` and writes it + to `writefile`. + """ + super(WriterThread, self).__init__() + self.writefile = writefile + self.readfile = readfile + self.blocksize = blocksize + self.daemon = True + + def run(self): + while True: + data = self.readfile.read(self.blocksize) + if data: + self.writefile.write(data) + else: + # EOF. + break + self.writefile.close() + + def popen_multiple(commands, command_args, *args, **kwargs): """Like `subprocess.Popen`, but can try multiple commands in case some are not available. @@ -100,7 +125,19 @@ def popen_multiple(commands, command_args, *args, **kwargs): class FFmpegAudioFile(object): """An audio file decoded by the ffmpeg command-line utility.""" - def __init__(self, filename, block_size=4096): + def __init__(self, filename=None, audio=None, block_size=4096): + """Start decoding an audio file. + + Provide either `filename` to read from the filesystem or + `audio`, a file-like object, to read from an open stream. + """ + if filename: + self.from_file = True + elif audio: + self.from_file = False + else: + raise ValueError('one of `filename` or `audio` must be provided') + # On Windows, we need to disable the subprocess's crash dialog # in case it dies. Passing SEM_NOGPFAULTERRORBOX to SetErrorMode # disables this behavior. @@ -117,14 +154,17 @@ def __init__(self, filename, block_size=4096): previous_error_mode | SEM_NOGPFAULTERRORBOX ) + # Start the subprocess. try: + in_arg = filename if self.from_file else '-' self.devnull = open(os.devnull) + in_stream = self.devnull if self.from_file else subprocess.PIPE self.proc = popen_multiple( COMMANDS, - ['-i', filename, '-f', 's16le', '-'], + ['-i', in_arg, '-f', 's16le', '-'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, - stdin=self.devnull, + stdin=in_stream, ) except OSError: @@ -141,6 +181,14 @@ def __init__(self, filename, block_size=4096): finally: windows_error_mode_lock.release() + # If the input data comes from a stream, start a thread to write + # the compressed audio to the subprocess's standard input + # stream. + if not self.from_file: + self.stdin_writer = WriterThread(self.proc.stdin, audio, + block_size) + self.stdin_writer.start() + # Start another thread to consume the standard output of the # process, which contains raw audio data. self.stdout_reader = QueueReaderThread(self.proc.stdout, block_size) diff --git a/decode.py b/decode.py index 40d369a..dfba250 100644 --- a/decode.py +++ b/decode.py @@ -21,21 +21,31 @@ import contextlib -def decode(filename): - filename = os.path.abspath(os.path.expanduser(filename)) - if not os.path.exists(filename): - print("File not found.", file=sys.stderr) - sys.exit(1) +def decode(filename=None): + """Decode audio from a file on disk or, if no file is specified, + from the standard input. + """ + if filename: + filename = os.path.abspath(os.path.expanduser(filename)) + if not os.path.exists(filename): + print("File not found.", file=sys.stderr) + sys.exit(1) try: - with audioread.audio_open(filename) as f: + if filename: + f = audioread.audio_open(filename) + else: + f = audioread.decode(sys.stdin) + + with f: print('Input file: %i channels at %i Hz; %.1f seconds.' % (f.channels, f.samplerate, f.duration), file=sys.stderr) print('Backend:', str(type(f).__module__).split('.')[1], file=sys.stderr) - with contextlib.closing(wave.open(filename + '.wav', 'w')) as of: + outname = filename or 'out' + with contextlib.closing(wave.open(outname + '.wav', 'w')) as of: of.setnchannels(f.channels) of.setframerate(f.samplerate) of.setsampwidth(2) @@ -49,4 +59,7 @@ def decode(filename): if __name__ == '__main__': - decode(sys.argv[1]) + if sys.argv[1:]: + decode(sys.argv[1]) + else: + decode()