Spaces:
Running
Running
# This file is part of audioread. | |
# Copyright 2014, Adrian Sampson. | |
# | |
# Permission is hereby granted, free of charge, to any person obtaining | |
# a copy of this software and associated documentation files (the | |
# "Software"), to deal in the Software without restriction, including | |
# without limitation the rights to use, copy, modify, merge, publish, | |
# distribute, sublicense, and/or sell copies of the Software, and to | |
# permit persons to whom the Software is furnished to do so, subject to | |
# the following conditions: | |
# | |
# The above copyright notice and this permission notice shall be | |
# included in all copies or substantial portions of the Software. | |
"""Read audio data using the ffmpeg command line tool via its standard | |
output. | |
""" | |
import queue | |
import re | |
import subprocess | |
import sys | |
import threading | |
import time | |
from io import DEFAULT_BUFFER_SIZE | |
from .exceptions import DecodeError | |
from .base import AudioFile | |
COMMANDS = ('ffmpeg', 'avconv') | |
if sys.platform == "win32": | |
PROC_FLAGS = 0x08000000 | |
else: | |
PROC_FLAGS = 0 | |
class FFmpegError(DecodeError): | |
pass | |
class CommunicationError(FFmpegError): | |
"""Raised when the output of FFmpeg is not parseable.""" | |
class UnsupportedError(FFmpegError): | |
"""The file could not be decoded by FFmpeg.""" | |
class NotInstalledError(FFmpegError): | |
"""Could not find the ffmpeg binary.""" | |
class ReadTimeoutError(FFmpegError): | |
"""Reading from the ffmpeg command-line tool timed out.""" | |
class QueueReaderThread(threading.Thread): | |
"""A thread that consumes data from a filehandle and sends the data | |
over a Queue. | |
""" | |
def __init__(self, fh, blocksize=1024, discard=False): | |
super().__init__() | |
self.fh = fh | |
self.blocksize = blocksize | |
self.daemon = True | |
self.discard = discard | |
self.queue = None if discard else queue.Queue() | |
def run(self): | |
while True: | |
data = self.fh.read(self.blocksize) | |
if not self.discard: | |
self.queue.put(data) | |
if not data: | |
# Stream closed (EOF). | |
break | |
def popen_multiple(commands, command_args, *args, **kwargs): | |
"""Like `subprocess.Popen`, but can try multiple commands in case | |
some are not available. | |
`commands` is an iterable of command names and `command_args` are | |
the rest of the arguments that, when appended to the command name, | |
make up the full first argument to `subprocess.Popen`. The | |
other positional and keyword arguments are passed through. | |
""" | |
for i, command in enumerate(commands): | |
cmd = [command] + command_args | |
try: | |
return subprocess.Popen(cmd, *args, **kwargs) | |
except OSError: | |
if i == len(commands) - 1: | |
# No more commands to try. | |
raise | |
def available(): | |
"""Detect whether the FFmpeg backend can be used on this system. | |
""" | |
try: | |
proc = popen_multiple( | |
COMMANDS, | |
['-version'], | |
stdout=subprocess.PIPE, | |
stderr=subprocess.PIPE, | |
creationflags=PROC_FLAGS, | |
) | |
except OSError: | |
return False | |
else: | |
proc.wait() | |
return proc.returncode == 0 | |
# For Windows error switch management, we need a lock to keep the mode | |
# adjustment atomic. | |
windows_error_mode_lock = threading.Lock() | |
class FFmpegAudioFile(AudioFile): | |
"""An audio file decoded by the ffmpeg command-line utility.""" | |
def __init__(self, filename, block_size=DEFAULT_BUFFER_SIZE): | |
# On Windows, we need to disable the subprocess's crash dialog | |
# in case it dies. Passing SEM_NOGPFAULTERRORBOX to SetErrorMode | |
# disables this behavior. | |
windows = sys.platform.startswith("win") | |
if windows: | |
windows_error_mode_lock.acquire() | |
SEM_NOGPFAULTERRORBOX = 0x0002 | |
import ctypes | |
# We call SetErrorMode in two steps to avoid overriding | |
# existing error mode. | |
previous_error_mode = \ | |
ctypes.windll.kernel32.SetErrorMode(SEM_NOGPFAULTERRORBOX) | |
ctypes.windll.kernel32.SetErrorMode( | |
previous_error_mode | SEM_NOGPFAULTERRORBOX | |
) | |
try: | |
self.proc = popen_multiple( | |
COMMANDS, | |
['-i', filename, '-f', 's16le', '-'], | |
stdout=subprocess.PIPE, | |
stderr=subprocess.PIPE, | |
stdin=subprocess.DEVNULL, | |
creationflags=PROC_FLAGS, | |
) | |
except OSError: | |
raise NotInstalledError() | |
finally: | |
# Reset previous error mode on Windows. (We can change this | |
# back now because the flag was inherited by the subprocess; | |
# we don't need to keep it set in the parent process.) | |
if windows: | |
try: | |
import ctypes | |
ctypes.windll.kernel32.SetErrorMode(previous_error_mode) | |
finally: | |
windows_error_mode_lock.release() | |
# 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) | |
self.stdout_reader.start() | |
# Read relevant information from stderr. | |
self._get_info() | |
# Start a separate thread to read the rest of the data from | |
# stderr. This (a) avoids filling up the OS buffer and (b) | |
# collects the error output for diagnosis. | |
self.stderr_reader = QueueReaderThread(self.proc.stderr) | |
self.stderr_reader.start() | |
def read_data(self, timeout=10.0): | |
"""Read blocks of raw PCM data from the file.""" | |
# Read from stdout in a separate thread and consume data from | |
# the queue. | |
start_time = time.time() | |
while True: | |
# Wait for data to be available or a timeout. | |
data = None | |
try: | |
data = self.stdout_reader.queue.get(timeout=timeout) | |
if data: | |
yield data | |
else: | |
# End of file. | |
break | |
except queue.Empty: | |
# Queue read timed out. | |
end_time = time.time() | |
if not data: | |
if end_time - start_time >= timeout: | |
# Nothing interesting has happened for a while -- | |
# FFmpeg is probably hanging. | |
raise ReadTimeoutError('ffmpeg output: {}'.format( | |
b''.join(self.stderr_reader.queue.queue) | |
)) | |
else: | |
start_time = end_time | |
# Keep waiting. | |
continue | |
def _get_info(self): | |
"""Reads the tool's output from its stderr stream, extracts the | |
relevant information, and parses it. | |
""" | |
out_parts = [] | |
while True: | |
line = self.proc.stderr.readline() | |
if not line: | |
# EOF and data not found. | |
raise CommunicationError("stream info not found") | |
# In Python 3, result of reading from stderr is bytes. | |
if isinstance(line, bytes): | |
line = line.decode('utf8', 'ignore') | |
line = line.strip().lower() | |
if 'no such file' in line: | |
raise OSError('file not found') | |
elif 'invalid data found' in line: | |
raise UnsupportedError() | |
elif 'duration:' in line: | |
out_parts.append(line) | |
elif 'audio:' in line: | |
out_parts.append(line) | |
self._parse_info(''.join(out_parts)) | |
break | |
def _parse_info(self, s): | |
"""Given relevant data from the ffmpeg output, set audio | |
parameter fields on this object. | |
""" | |
# Sample rate. | |
match = re.search(r'(\d+) hz', s) | |
if match: | |
self.samplerate = int(match.group(1)) | |
else: | |
self.samplerate = 0 | |
# Channel count. | |
match = re.search(r'hz, ([^,]+),', s) | |
if match: | |
mode = match.group(1) | |
if mode == 'stereo': | |
self.channels = 2 | |
else: | |
cmatch = re.match(r'(\d+)\.?(\d)?', mode) | |
if cmatch: | |
self.channels = sum(map(int, cmatch.group().split('.'))) | |
else: | |
self.channels = 1 | |
else: | |
self.channels = 0 | |
# Duration. | |
match = re.search( | |
r'duration: (\d+):(\d+):(\d+).(\d)', s | |
) | |
if match: | |
durparts = list(map(int, match.groups())) | |
duration = ( | |
durparts[0] * 60 * 60 + | |
durparts[1] * 60 + | |
durparts[2] + | |
float(durparts[3]) / 10 | |
) | |
self.duration = duration | |
else: | |
# No duration found. | |
self.duration = 0 | |
def close(self): | |
"""Close the ffmpeg process used to perform the decoding.""" | |
if hasattr(self, 'proc'): | |
# First check the process's execution status before attempting to | |
# kill it. This fixes an issue on Windows Subsystem for Linux where | |
# ffmpeg closes normally on its own, but never updates | |
# `returncode`. | |
self.proc.poll() | |
# Kill the process if it is still running. | |
if self.proc.returncode is None: | |
self.proc.kill() | |
self.proc.wait() | |
# Wait for the stream-reading threads to exit. (They need to | |
# stop reading before we can close the streams.) | |
if hasattr(self, 'stderr_reader'): | |
self.stderr_reader.join() | |
if hasattr(self, 'stdout_reader'): | |
self.stdout_reader.join() | |
# Close the stdout and stderr streams that were opened by Popen, | |
# which should occur regardless of if the process terminated | |
# cleanly. | |
self.proc.stdout.close() | |
self.proc.stderr.close() | |
def __del__(self): | |
self.close() | |
# Iteration. | |
def __iter__(self): | |
return self.read_data() | |
# Context manager. | |
def __enter__(self): | |
return self | |
def __exit__(self, exc_type, exc_val, exc_tb): | |
self.close() | |
return False | |