228 lines
6.4 KiB
Python
228 lines
6.4 KiB
Python
from contextlib import contextmanager
|
|
import queue
|
|
import time
|
|
|
|
from abc import ABC, abstractmethod
|
|
from logging import getLogger
|
|
from threading import Event, RLock, Thread
|
|
from typing import IO, Generator, Optional, Tuple, Union
|
|
from typing_extensions import override
|
|
|
|
import sounddevice as sd
|
|
|
|
from .._converter import ConverterProcess
|
|
from .._model import AudioState
|
|
|
|
|
|
class AudioThread(Thread, ABC):
|
|
"""
|
|
Base class for audio play/record threads.
|
|
"""
|
|
|
|
_STREAM_NAME_PREFIX = 'platypush-stream-'
|
|
|
|
def __init__(
|
|
self,
|
|
plugin,
|
|
device: Union[str, Tuple[str, str]],
|
|
outfile: str,
|
|
output_format: str,
|
|
channels: int,
|
|
sample_rate: int,
|
|
dtype: str,
|
|
stream: bool,
|
|
audio_pass_through: bool,
|
|
duration: Optional[float] = None,
|
|
blocksize: Optional[int] = None,
|
|
latency: Union[float, str] = 'high',
|
|
redis_queue: Optional[str] = None,
|
|
should_stop: Optional[Event] = None,
|
|
**kwargs,
|
|
):
|
|
from .. import SoundPlugin
|
|
|
|
super().__init__(**kwargs)
|
|
|
|
self.plugin: SoundPlugin = plugin
|
|
self.device = device
|
|
self.outfile = outfile
|
|
self.output_format = output_format
|
|
self.channels = channels
|
|
self.sample_rate = sample_rate
|
|
self.dtype = dtype
|
|
self.stream = stream
|
|
self.duration = duration
|
|
self.blocksize = blocksize
|
|
self.latency = latency
|
|
self.redis_queue = redis_queue
|
|
self.audio_pass_through = audio_pass_through
|
|
self.logger = getLogger(__name__)
|
|
|
|
self._state = AudioState.STOPPED
|
|
self._state_lock = RLock()
|
|
self._started_time: Optional[float] = None
|
|
self._converter: Optional[ConverterProcess] = None
|
|
self._should_stop = should_stop or Event()
|
|
self.paused_changed = Event()
|
|
|
|
@property
|
|
def should_stop(self) -> bool:
|
|
"""
|
|
Proxy for `._should_stop.is_set()`.
|
|
"""
|
|
return self._should_stop.is_set()
|
|
|
|
@abstractmethod
|
|
def _audio_callback(self, audio_converter: ConverterProcess):
|
|
"""
|
|
Returns a callback to handle the raw frames captures from the audio device.
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
@abstractmethod
|
|
def _on_audio_converted(self, data: bytes, out_f: IO):
|
|
"""
|
|
This callback will be called when the audio data has been converted.
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
def main(
|
|
self,
|
|
converter: ConverterProcess,
|
|
audio_stream: sd.Stream,
|
|
out_stream_index: Optional[int],
|
|
out_f: IO,
|
|
):
|
|
"""
|
|
Main loop.
|
|
"""
|
|
self.notify_start()
|
|
if out_stream_index:
|
|
self.plugin.start_playback(
|
|
stream_index=out_stream_index, stream=audio_stream
|
|
)
|
|
|
|
self.logger.info(
|
|
'Started %s on device [%s]', self.__class__.__name__, self.device
|
|
)
|
|
self._started_time = time.time()
|
|
|
|
while (
|
|
self.state != AudioState.STOPPED
|
|
and not self.should_stop
|
|
and (
|
|
self.duration is None
|
|
or time.time() - self._started_time < self.duration
|
|
)
|
|
):
|
|
while self.state == AudioState.PAUSED:
|
|
self.paused_changed.wait()
|
|
|
|
if self.should_stop:
|
|
break
|
|
|
|
timeout = (
|
|
max(
|
|
0,
|
|
self.duration - (time.time() - self._started_time),
|
|
)
|
|
if self.duration is not None
|
|
else 1
|
|
)
|
|
|
|
data = converter.read(timeout=timeout)
|
|
if not data:
|
|
continue
|
|
|
|
self._on_audio_converted(data, out_f)
|
|
|
|
@override
|
|
def run(self):
|
|
super().run()
|
|
self.paused_changed.clear()
|
|
|
|
try:
|
|
stream_index = (
|
|
self.plugin._allocate_stream_index() # pylint: disable=protected-access
|
|
if self.audio_pass_through
|
|
else None
|
|
)
|
|
|
|
with self.open_converter() as converter, sd.Stream(
|
|
samplerate=self.sample_rate,
|
|
device=self.device,
|
|
channels=self.channels,
|
|
callback=self._audio_callback(converter),
|
|
dtype=self.dtype,
|
|
latency=self.latency,
|
|
blocksize=self.blocksize,
|
|
) as audio_stream, open(self.outfile, 'wb') as f:
|
|
self.main(
|
|
out_stream_index=stream_index,
|
|
converter=converter,
|
|
audio_stream=audio_stream,
|
|
out_f=f,
|
|
)
|
|
except queue.Empty:
|
|
self.logger.warning(
|
|
'Audio callback timeout for %s', self.__class__.__name__
|
|
)
|
|
finally:
|
|
self.notify_stop()
|
|
|
|
@contextmanager
|
|
def open_converter(self) -> Generator[ConverterProcess, None, None]:
|
|
assert not self._converter, 'A converter process is already running'
|
|
self._converter = ConverterProcess(
|
|
ffmpeg_bin=self.plugin.ffmpeg_bin,
|
|
sample_rate=self.sample_rate,
|
|
channels=self.channels,
|
|
dtype=self.dtype,
|
|
chunk_size=self.plugin.input_blocksize,
|
|
output_format=self.output_format,
|
|
)
|
|
|
|
self._converter.start()
|
|
yield self._converter
|
|
|
|
self._converter.stop()
|
|
self._converter.join(timeout=2)
|
|
self._converter = None
|
|
|
|
def notify_start(self):
|
|
self.state = AudioState.RUNNING
|
|
|
|
def notify_stop(self):
|
|
self.state = AudioState.STOPPED
|
|
if self._converter:
|
|
self._converter.stop()
|
|
|
|
def notify_pause(self):
|
|
states = {
|
|
AudioState.PAUSED: AudioState.RUNNING,
|
|
AudioState.RUNNING: AudioState.PAUSED,
|
|
}
|
|
|
|
with self._state_lock:
|
|
new_state = states.get(self.state)
|
|
if new_state:
|
|
self.state = new_state
|
|
else:
|
|
return
|
|
|
|
self.logger.info('Paused state toggled for %s', self.__class__.__name__)
|
|
self.paused_changed.set()
|
|
|
|
@property
|
|
def state(self):
|
|
with self._state_lock:
|
|
return self._state
|
|
|
|
@state.setter
|
|
def state(self, value: AudioState):
|
|
with self._state_lock:
|
|
self._state = value
|
|
|
|
|
|
# vim:sw=4:ts=4:et:
|