platypush/platypush/plugins/sound/_converters/_base.py

303 lines
8.8 KiB
Python

from abc import ABC, abstractmethod
import asyncio
from asyncio.subprocess import PIPE
from logging import getLogger
from queue import Empty, Queue
from threading import Event, RLock, Thread
from typing import Any, Callable, Coroutine, Iterable, Optional
from platypush.context import get_or_create_event_loop
from platypush.utils import is_debug_enabled
dtype_to_ffmpeg_format = {
'int8': 's8',
'uint8': 'u8',
'int16': 's16le',
'uint16': 'u16le',
'int32': 's32le',
'uint32': 'u32le',
'float32': 'f32le',
'float64': 'f64le',
}
"""
Supported raw types:
'int8', 'uint8', 'int16', 'uint16', 'int32', 'uint32', 'float32', 'float64'
"""
class AudioConverter(Thread, ABC):
"""
Base class for an ffmpeg audio converter instance.
"""
_format_to_ffmpeg_args = {
'wav': ('-f', 'wav'),
'ogg': ('-f', 'ogg'),
'mp3': ('-f', 'mp3'),
'aac': ('-f', 'adts'),
'flac': ('-f', 'flac'),
}
def __init__(
self,
*args,
ffmpeg_bin: str,
sample_rate: int,
channels: int,
volume: float,
dtype: str,
chunk_size: int,
input_format: Optional[str] = None, # pylint: disable=redefined-builtin
output_format: Optional[str] = None, # pylint: disable=redefined-builtin
on_exit: Optional[Callable[[], Any]] = None,
**kwargs,
):
"""
:param ffmpeg_bin: Path to the ffmpeg binary.
:param sample_rate: The sample rate of the input/output audio.
:param channels: The number of channels of the input/output audio.
:param volume: Audio volume, as a percentage between 0 and 100.
:param dtype: The (numpy) data type of the raw input/output audio.
:param chunk_size: Number of bytes that will be read at once from the
ffmpeg process.
:param input_format: Input audio format.
:param output_format: Output audio format.
:param on_exit: Function to call when the ffmpeg process exits.
"""
super().__init__(*args, **kwargs)
self._ffmpeg_bin = ffmpeg_bin
self._ffmpeg_task: Optional[Coroutine] = None
self._sample_rate = sample_rate
self._channels = channels
self._chunk_size = chunk_size
self._input_format = input_format
self._output_format = output_format
self._dtype = dtype
self._closed = False
self._out_queue = Queue()
self.ffmpeg = None
self.volume = volume
self.logger = getLogger(__name__)
self._loop = None
self._should_stop = Event()
self._stop_lock = RLock()
self._on_exit = on_exit
self._ffmpeg_terminated = Event()
def __enter__(self) -> "AudioConverter":
"""
Audio converter context manager.
It starts and registers the ffmpeg converter process.
"""
self.start()
return self
def __exit__(self, *_, **__):
"""
Audio converter context manager.
It stops and unregisters the ffmpeg converter process.
"""
self.stop()
def _check_ffmpeg(self):
assert not self.terminated, 'The ffmpeg process has already terminated'
@property
def gain(self) -> float:
return self.volume / 100
@property
def terminated(self) -> bool:
return self._ffmpeg_terminated.is_set()
@property
def _default_args(self) -> Iterable[str]:
"""
Set of arguments common to all ffmpeg converter instances.
"""
log_level = 'debug' if is_debug_enabled() else 'warning'
return ('-hide_banner', '-loglevel', log_level, '-y')
@property
@abstractmethod
def _input_format_args(self) -> Iterable[str]:
"""
Ffmpeg audio input arguments.
"""
raise NotImplementedError()
@property
@abstractmethod
def _output_format_args(self):
"""
Ffmpeg audio output arguments.
"""
raise NotImplementedError()
@property
def _channel_layout_args(self) -> Iterable[str]:
"""
Set of extra ffmpeg arguments for the channel layout.
"""
args = ('-ac', str(self._channels))
if self._channels == 1:
return args + ('-channel_layout', 'mono')
if self._channels == 2:
return args + ('-channel_layout', 'stereo')
return args
@property
def _audio_volume_args(self) -> Iterable[str]:
"""
Ffmpeg audio volume arguments.
"""
return ('-filter:a', f'volume={self.gain}')
@property
def _input_source_args(self) -> Iterable[str]:
"""
Default arguments for the ffmpeg input source (default: ``-i pipe:``,
ffmpeg will read from a pipe filled by the application).
"""
return ('-i', 'pipe:')
@property
def _output_target_args(self) -> Iterable[str]:
"""
Default arguments for the ffmpeg output target (default: ``pipe:``,
ffmpeg will write the output to a pipe read by the application).
"""
return ('pipe:',)
@property
def _converter_stdin(self) -> Optional[int]:
"""
Default stdin file descriptor to be used by the ffmpeg converter.
Default: ``PIPE``, as the ffmpeg process by default reads audio frames
from the stdin.
"""
return PIPE
async def _audio_proxy(self, timeout: Optional[float] = None):
"""
Proxy the converted audio stream to the output queue for downstream
consumption.
"""
ffmpeg_args = (
self._ffmpeg_bin,
*self._default_args,
*self._input_format_args,
*self._input_source_args,
*self._output_format_args,
*self._output_target_args,
)
self.ffmpeg = await asyncio.create_subprocess_exec(
*ffmpeg_args,
stdin=self._converter_stdin,
stdout=PIPE,
)
self.logger.info('Running ffmpeg: %s', ' '.join(ffmpeg_args))
while (
self._loop
and self.ffmpeg
and self.ffmpeg.returncode is None
and not self.should_stop
):
self._check_ffmpeg()
assert (
self.ffmpeg and self.ffmpeg.stdout
), 'The stdout is closed for the ffmpeg process'
self._ffmpeg_terminated.clear()
try:
reader = asyncio.create_task(self.ffmpeg.stdout.read(self._chunk_size))
data = await asyncio.wait_for(reader, timeout)
self._out_queue.put(data)
except asyncio.TimeoutError:
pass
except Exception as e:
self.logger.warning('Audio proxy error: %s', e)
break
self._out_queue.put(b'')
def write(self, data: bytes):
"""
Write raw data to the ffmpeg process.
"""
self._check_ffmpeg()
assert (
self.ffmpeg and self._loop and self.ffmpeg.stdin
), 'The stdin is closed for the ffmpeg process'
self._loop.call_soon_threadsafe(self.ffmpeg.stdin.write, data)
def read(self, timeout: Optional[float] = None) -> Optional[bytes]:
"""
Read the next chunk of converted audio bytes from the converter queue.
"""
try:
return self._out_queue.get(timeout=timeout)
except Empty:
return None
def run(self):
"""
Main runner. It runs the audio proxy in a loop and cleans up everything
in case of stop/failure.
"""
super().run()
self._loop = get_or_create_event_loop()
try:
self._ffmpeg_task = self._audio_proxy(timeout=1)
self._loop.run_until_complete(self._ffmpeg_task)
except RuntimeError as e:
self.logger.warning(e)
except Exception as e:
self.logger.warning('Audio converter error: %s', e)
self.logger.exception(e)
finally:
self.stop()
def stop(self):
"""
Sets the stop event, kills the ffmpeg process and resets the context.
"""
with self._stop_lock:
self._should_stop.set()
if self._ffmpeg_task:
self._ffmpeg_task.close()
self._ffmpeg_task = None
try:
if self.ffmpeg and self.ffmpeg.returncode is None:
self.ffmpeg.kill()
except ProcessLookupError:
pass
self.ffmpeg = None
self._loop = None
self._ffmpeg_terminated.set()
if self._on_exit:
self._on_exit()
@property
def should_stop(self) -> bool:
"""
Proxy property for the ``_should_stop`` event.
"""
return self._should_stop.is_set()
# vim:sw=4:ts=4:et: