303 lines
8.8 KiB
Python
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:
|