Skip to content

Commit

Permalink
Add oneshot decoder
Browse files Browse the repository at this point in the history
  • Loading branch information
joetoddsonos committed Apr 10, 2024
1 parent 3952eac commit 0698711
Show file tree
Hide file tree
Showing 4 changed files with 121 additions and 12 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down
6 changes: 4 additions & 2 deletions pyflac/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -19,6 +19,7 @@
'EncoderProcessException',
'StreamDecoder',
'FileDecoder',
'OneShotDecoder',
'DecoderState',
'DecoderInitException',
'DecoderProcessException'
Expand Down Expand Up @@ -55,6 +56,7 @@
from .decoder import (
StreamDecoder,
FileDecoder,
OneShotDecoder,
DecoderState,
DecoderInitException,
DecoderProcessException
Expand Down
96 changes: 87 additions & 9 deletions pyflac/decoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -348,29 +429,26 @@ 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
# clear the event, the thread will await more data to process.
# --------------------------------------------------------------
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


Expand Down
27 changes: 27 additions & 0 deletions tests/test_decoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from pyflac import (
FileDecoder,
StreamDecoder,
OneShotDecoder,
DecoderState,
DecoderInitException,
DecoderProcessException
Expand Down Expand Up @@ -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)

0 comments on commit 0698711

Please sign in to comment.