|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
"""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: |
|
|
|
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: |
|
|
|
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 |
|
|
|
|
|
|
|
|
|
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): |
|
|
|
|
|
|
|
windows = sys.platform.startswith("win") |
|
if windows: |
|
windows_error_mode_lock.acquire() |
|
SEM_NOGPFAULTERRORBOX = 0x0002 |
|
import ctypes |
|
|
|
|
|
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: |
|
|
|
|
|
|
|
if windows: |
|
try: |
|
import ctypes |
|
ctypes.windll.kernel32.SetErrorMode(previous_error_mode) |
|
finally: |
|
windows_error_mode_lock.release() |
|
|
|
|
|
|
|
self.stdout_reader = QueueReaderThread(self.proc.stdout, block_size) |
|
self.stdout_reader.start() |
|
|
|
|
|
self._get_info() |
|
|
|
|
|
|
|
|
|
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.""" |
|
|
|
|
|
start_time = time.time() |
|
while True: |
|
|
|
data = None |
|
try: |
|
data = self.stdout_reader.queue.get(timeout=timeout) |
|
if data: |
|
yield data |
|
else: |
|
|
|
break |
|
except queue.Empty: |
|
|
|
end_time = time.time() |
|
if not data: |
|
if end_time - start_time >= timeout: |
|
|
|
|
|
raise ReadTimeoutError('ffmpeg output: {}'.format( |
|
b''.join(self.stderr_reader.queue.queue) |
|
)) |
|
else: |
|
start_time = end_time |
|
|
|
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: |
|
|
|
raise CommunicationError("stream info not found") |
|
|
|
|
|
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. |
|
""" |
|
|
|
match = re.search(r'(\d+) hz', s) |
|
if match: |
|
self.samplerate = int(match.group(1)) |
|
else: |
|
self.samplerate = 0 |
|
|
|
|
|
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 |
|
|
|
|
|
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: |
|
|
|
self.duration = 0 |
|
|
|
def close(self): |
|
"""Close the ffmpeg process used to perform the decoding.""" |
|
if hasattr(self, 'proc'): |
|
|
|
|
|
|
|
|
|
self.proc.poll() |
|
|
|
|
|
if self.proc.returncode is None: |
|
self.proc.kill() |
|
self.proc.wait() |
|
|
|
|
|
|
|
if hasattr(self, 'stderr_reader'): |
|
self.stderr_reader.join() |
|
if hasattr(self, 'stdout_reader'): |
|
self.stdout_reader.join() |
|
|
|
|
|
|
|
|
|
self.proc.stdout.close() |
|
self.proc.stderr.close() |
|
|
|
def __del__(self): |
|
self.close() |
|
|
|
|
|
def __iter__(self): |
|
return self.read_data() |
|
|
|
|
|
def __enter__(self): |
|
return self |
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb): |
|
self.close() |
|
return False |
|
|