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