From 06987118f57cd0a30ec0b9c29328fb2f1ae579d9 Mon Sep 17 00:00:00 2001 From: Joe Todd Date: Wed, 10 Apr 2024 17:44:21 +0100 Subject: [PATCH] Add oneshot decoder --- CHANGELOG.rst | 4 +- pyflac/__init__.py | 6 ++- pyflac/decoder.py | 96 +++++++++++++++++++++++++++++++++++++++---- tests/test_decoder.py | 27 ++++++++++++ 4 files changed, 121 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 634b67a..cd9b68a 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,9 +3,11 @@ pyFLAC Changelog **v3.0.0** -* Fixed bug in the shutdown behaviour of the decoder (see #22 and #23). +* Fixed bug in the shutdown behaviour of the `StreamDecoder` (see #22 and #23). * Automatically detect bit depth of input data in the `FileEncoder`, and raise an error if not 16-bit or 32-bit PCM (see #24). +* Added a new `OneShotDecoder` to decode a buffer of FLAC data in a single + blocking operation, without the use of threads. Courtesy of @GOAE. **v2.2.0** diff --git a/pyflac/__init__.py b/pyflac/__init__.py index fb72601..dd02e70 100644 --- a/pyflac/__init__.py +++ b/pyflac/__init__.py @@ -4,13 +4,13 @@ # # pyFLAC # -# Copyright (c) 2020-2021, Sonos, Inc. +# Copyright (c) 2020-2024, Sonos, Inc. # All rights reserved. # # ------------------------------------------------------------------------------ __title__ = 'pyFLAC' -__version__ = '2.2.0' +__version__ = '3.0.0' __all__ = [ 'StreamEncoder', 'FileEncoder', @@ -19,6 +19,7 @@ 'EncoderProcessException', 'StreamDecoder', 'FileDecoder', + 'OneShotDecoder', 'DecoderState', 'DecoderInitException', 'DecoderProcessException' @@ -55,6 +56,7 @@ from .decoder import ( StreamDecoder, FileDecoder, + OneShotDecoder, DecoderState, DecoderInitException, DecoderProcessException diff --git a/pyflac/decoder.py b/pyflac/decoder.py index 625b957..ae6e572 100644 --- a/pyflac/decoder.py +++ b/pyflac/decoder.py @@ -122,6 +122,9 @@ class StreamDecoder(_Decoder): blocks of raw uncompressed audio is passed back to the user via the `callback`. + The `finish` method must be called at the end of the decoding process, + otherwise the processing thread will be left running. + Args: write_callback (fn): Function to call when there is uncompressed audio data ready, see the example below for more information. @@ -232,7 +235,7 @@ def finish(self): # -------------------------------------------------------------- self._done = True self._event.set() - self._thread.join(timeout=3) + self._thread.join() super().finish() if self._error: raise DecoderProcessException(self._error) @@ -310,6 +313,84 @@ def _write_callback(self, data: np.ndarray, sample_rate: int, num_channels: int, self.__output.write(data) +class OneShotDecoder(_Decoder): + """ + A pyFLAC one-shot decoder converts a buffer of FLAC encoded + bytes back to raw audio data. Unlike the `StreamDecoder` class, + the one-shot decoder operates on a single block of data, and + runs in a blocking manner, as opposed to in a background thread. + + The compressed data is passed in via the constructor, and + blocks of raw uncompressed audio is passed back to the user via + the `callback`. + + Args: + write_callback (fn): Function to call when there is uncompressed + audio data ready, see the example below for more information. + buffer (bytes): The FLAC encoded audio data + + Examples: + An example callback which writes the audio data to file + using SoundFile. + + .. code-block:: python + :linenos: + + import soundfile as sf + + def callback(self, + audio: np.ndarray, + sample_rate: int, + num_channels: int, + num_samples: int): + + # ------------------------------------------------------ + # Note: num_samples is the number of samples per channel + # ------------------------------------------------------ + if self.output is None: + self.output = sf.SoundFile( + 'output.wav', mode='w', channels=num_channels, + samplerate=sample_rate + ) + self.output.write(audio) + + Raises: + DecoderInitException: If initialisation of the decoder fails + """ + def __init__(self, + write_callback: Callable[[np.ndarray, int, int, int], None], + buffer: bytes): + super().__init__() + self._done = False + self._buffer = deque() + self._buffer.append(buffer) + self._event = threading.Event() + self._event.set() + self._lock = threading.Lock() + self.write_callback = write_callback + + rc = _lib.FLAC__stream_decoder_init_stream( + self._decoder, + _lib._read_callback, + _ffi.NULL, + _ffi.NULL, + _ffi.NULL, + _ffi.NULL, + _lib._write_callback, + _ffi.NULL, + _lib._error_callback, + self._decoder_handle + ) + if rc != _lib.FLAC__STREAM_DECODER_INIT_STATUS_OK: + raise DecoderInitException(rc) + + while len(self._buffer) > 0: + _lib.FLAC__stream_decoder_process_single(self._decoder) + + self._done = True + super().finish() + + @_ffi.def_extern(error=_lib.FLAC__STREAM_DECODER_READ_STATUS_ABORT) def _read_callback(_decoder, byte_buffer, @@ -348,21 +429,14 @@ def _read_callback(_decoder, # -------------------------------------------------------------- data = bytes() maximum_bytes = int(num_bytes[0]) + decoder._lock.acquire() if len(decoder._buffer[0]) <= maximum_bytes: - decoder._lock.acquire() data = decoder._buffer.popleft() - decoder._lock.release() maximum_bytes -= len(data) if len(decoder._buffer) > 0 and len(decoder._buffer[0]) > maximum_bytes: - decoder._lock.acquire() data += decoder._buffer[0][0:maximum_bytes] decoder._buffer[0] = decoder._buffer[0][maximum_bytes:] - decoder._lock.release() - - actual_bytes = len(data) - num_bytes[0] = actual_bytes - _ffi.memmove(byte_buffer, data, actual_bytes) # -------------------------------------------------------------- # If there is no more data to process from the buffer, then @@ -370,7 +444,11 @@ def _read_callback(_decoder, # -------------------------------------------------------------- if len(decoder._buffer) == 0 or (len(decoder._buffer) > 0 and len(decoder._buffer[0]) == 0): decoder._event.clear() + decoder._lock.release() + actual_bytes = len(data) + num_bytes[0] = actual_bytes + _ffi.memmove(byte_buffer, data, actual_bytes) return _lib.FLAC__STREAM_DECODER_READ_STATUS_CONTINUE diff --git a/tests/test_decoder.py b/tests/test_decoder.py index 7998efc..61b2d43 100644 --- a/tests/test_decoder.py +++ b/tests/test_decoder.py @@ -20,6 +20,7 @@ from pyflac import ( FileDecoder, StreamDecoder, + OneShotDecoder, DecoderState, DecoderInitException, DecoderProcessException @@ -150,5 +151,31 @@ def test_process_32_bit_file(self): self.assertIsNotNone(self.decoder.process()) +class TestOneShotDecoder(unittest.TestCase): + """ + Test suite for the one-shot decoder class. + """ + def setUp(self): + self.decoder = None + self.write_callback_called = False + self.tests_path = pathlib.Path(__file__).parent.absolute() + + def _write_callback(self, data, rate, channels, samples): + assert isinstance(data, np.ndarray) + assert isinstance(rate, int) + assert isinstance(channels, int) + assert isinstance(samples, int) + self.write_callback_called = True + + def test_process(self): + """ Test that FLAC data can be decoded """ + test_path = self.tests_path / 'data/stereo.flac' + with open(test_path, 'rb') as flac: + test_data = flac.read() + + self.decoder = OneShotDecoder(write_callback=self._write_callback, buffer=test_data) + self.assertTrue(self.write_callback_called) + + if __name__ == '__main__': unittest.main(failfast=True)